Compare commits
45 Commits
d9bb9363dd
...
claude/pra
| Author | SHA1 | Date | |
|---|---|---|---|
| b0282b7f8b | |||
| ac72905ecb | |||
| 3bf6b8c6c9 | |||
| 4759374883 | |||
| cb6e34d5ce | |||
| 2b72951e66 | |||
| 69dad7cc74 | |||
| efa5aca35f | |||
| c429dcc033 | |||
| 9146118df1 | |||
| 07d15001ae | |||
| a0b366e94a | |||
| 3790a3bd9e | |||
| 0a07c61ca3 | |||
| 337b6061b2 | |||
| 467fdd2a6c | |||
| c12ad94b7f | |||
| 05e241c792 | |||
| 5ee4c71fc1 | |||
| 81908e48ea | |||
| 6230558b91 | |||
| 2c215353d4 | |||
| d526025926 | |||
| 411239bea4 | |||
| 7e6c4c16ce | |||
| ea0e3d6f29 | |||
| 8db89373e0 | |||
| e719f593de | |||
| 16adfd6f26 | |||
| 704f29362a | |||
| 42767e3119 | |||
| a550a8d0be | |||
| 063741adc7 | |||
| ad2f16d93b | |||
| b0b36df4e4 | |||
| aa7f2dab32 | |||
| d2d553eed6 | |||
| 2ca277b6e6 | |||
| bfcbe086f2 | |||
| c92eb1b57b | |||
| 07347a644f | |||
| f1e66966f3 | |||
| d1c40c633f | |||
| 0e82e238c1 | |||
| 2fed5d6ce1 |
@@ -12,3 +12,8 @@ AI_API_KEY=your_nvidia_api_key_here
|
|||||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
# Set to 'true' to enable detailed AI prompt/response logging
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
AI_DEBUG=false
|
AI_DEBUG=false
|
||||||
|
|
||||||
|
# New Relic APM — set ENABLED=true and provide your license key to activate
|
||||||
|
NEW_RELIC_ENABLED=false
|
||||||
|
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
|
||||||
|
NEW_RELIC_APP_NAME=HOALedgerIQ_App
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,11 @@ postgres_data/
|
|||||||
redis_data/
|
redis_data/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
|
# Database backups
|
||||||
|
backups/
|
||||||
|
*.dump
|
||||||
|
*.dump.gz
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
letsencrypt/
|
letsencrypt/
|
||||||
|
|
||||||
|
|||||||
136
PLAN.md
Normal file
136
PLAN.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Phase 2 Bug Fix & Tweaks - Implementation Plan
|
||||||
|
|
||||||
|
## 1. Admin Panel: Tenant Creation, Contract/Plan Fields, Disable/Archive
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
- Add `contract_number VARCHAR(100)` and `plan_level VARCHAR(50) DEFAULT 'standard'` to `shared.organizations` (live DB ALTER + init SQL)
|
||||||
|
- Add `archived` to the status CHECK constraint: `('active', 'suspended', 'trial', 'archived')`
|
||||||
|
- Add to Organization entity: `contractNumber`, `planLevel` columns
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **admin.controller.ts**: Add two new endpoints:
|
||||||
|
- `POST /admin/tenants` — Creates org + first user + tenant schema in one call. Accepts: org name, email, address, contractNumber, planLevel, plus first user's email/password/firstName/lastName. Calls OrganizationsService.create() then sets up the user.
|
||||||
|
- `PUT /admin/organizations/:id/status` — Sets status to 'active', 'suspended', or 'archived'
|
||||||
|
- **auth.module.ts**: Import OrganizationsModule so AdminController can inject OrganizationsService
|
||||||
|
- **auth.service.ts**: In `login()`, after loading user with orgs, check if the default org's status is 'suspended' or 'archived' → throw UnauthorizedException("Your organization has been suspended/archived")
|
||||||
|
- **users.service.ts**: Update `findAllOrganizations()` query to include `contract_number, plan_level` in the SELECT
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **AdminPage.tsx**:
|
||||||
|
- Add "Create Tenant" button → opens a modal with: org name, address, email, phone, contract number, plan level (select: standard/premium/enterprise), first admin email, first admin password, first/last name
|
||||||
|
- Orgs table: add Contract #, Plan Level columns
|
||||||
|
- Orgs table: add Status dropdown/buttons (Active/Suspended/Archived) per row with confirmation
|
||||||
|
- Show status colors: active=green, trial=yellow, suspended=orange, archived=red
|
||||||
|
|
||||||
|
## 2. Units/Homeowners: Delete + Assessment Group Binding
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **units.controller.ts**: Add `@Delete(':id')` route
|
||||||
|
- **units.service.ts**:
|
||||||
|
- Add `delete(id)` method — checks for outstanding invoices first, then deletes
|
||||||
|
- Add `assessment_group_id` to `create()` INSERT and `update()` UPDATE queries
|
||||||
|
- Update `findAll()` to JOIN assessment_groups and return `assessment_group_name`
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **UnitsPage.tsx**:
|
||||||
|
- Add delete button (trash icon) per row with confirmation dialog
|
||||||
|
- Add Assessment Group dropdown (Select) in create/edit modal, populated from `/assessment-groups` query
|
||||||
|
- Show assessment group name in table
|
||||||
|
- When an assessment group is selected and no manual monthly_assessment is set, auto-fill from the group's regular_assessment
|
||||||
|
|
||||||
|
## 3. Assessment Groups: Frequency Field
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
- Add `frequency VARCHAR(20) DEFAULT 'monthly'` to `assessment_groups` table (live DB ALTER + tenant-schema DDL)
|
||||||
|
- CHECK constraint: `('monthly', 'quarterly', 'annual')`
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **assessment-groups.service.ts**:
|
||||||
|
- Add `frequency` to `create()` INSERT
|
||||||
|
- Add `frequency` to `update()` dynamic sets
|
||||||
|
- Update `findAll()` and `getSummary()` income calculations to adjust by frequency:
|
||||||
|
- monthly → multiply by 1 (×12/year)
|
||||||
|
- quarterly → amounts are per quarter, so monthly = amount/3
|
||||||
|
- annual → amounts are per year, so monthly = amount/12
|
||||||
|
- Summary labels should change to reflect "Monthly Equivalent" for mixed frequencies
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **AssessmentGroupsPage.tsx**:
|
||||||
|
- Add frequency Select in create/edit modal: Monthly, Quarterly, Annual
|
||||||
|
- Show frequency badge in table
|
||||||
|
- Update summary cards: labels → "Monthly Equivalent Operating" etc.
|
||||||
|
- Assessment amount label changes based on frequency ("Per Month" / "Per Quarter" / "Per Year")
|
||||||
|
|
||||||
|
## 4. UI Streamlining: Sidebar Grouping, Rename, Logo
|
||||||
|
|
||||||
|
### Sidebar Restructure
|
||||||
|
Group nav items into labeled sections:
|
||||||
|
```
|
||||||
|
Dashboard
|
||||||
|
─── FINANCIALS ───
|
||||||
|
Accounts (renamed from "Chart of Accounts")
|
||||||
|
Budgets
|
||||||
|
Investments
|
||||||
|
─── ASSESSMENTS ───
|
||||||
|
Units / Homeowners
|
||||||
|
Assessment Groups
|
||||||
|
─── TRANSACTIONS ───
|
||||||
|
Transactions
|
||||||
|
Invoices
|
||||||
|
Payments
|
||||||
|
─── PLANNING ───
|
||||||
|
Capital Projects
|
||||||
|
Reserves
|
||||||
|
Vendors
|
||||||
|
─── REPORTS ───
|
||||||
|
(collapsible with sub-items)
|
||||||
|
─── ADMIN ───
|
||||||
|
Year-End
|
||||||
|
Settings
|
||||||
|
─── PLATFORM ADMIN ─── (superadmin only)
|
||||||
|
Admin Panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
- Copy SVG to `frontend/src/assets/logo.svg`
|
||||||
|
- In AppLayout.tsx: Replace `<Title order={3} c="blue">HOA LedgerIQ</Title>` with an `<img>` tag loading the SVG, sized to fit the 60px header (height ~40px with padding)
|
||||||
|
- SVG will be served directly (Vite handles SVG imports natively), no PNG conversion needed since browsers render SVG natively and it's cleaner
|
||||||
|
|
||||||
|
## 5. Capital Projects: PDF Table Export, Kanban Default, Future Category
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **CapitalProjectsPage.tsx**:
|
||||||
|
- Change default viewMode from `'table'` to `'kanban'`
|
||||||
|
- PDF export: temporarily switch to table view for print, then restore. Use `@media print` CSS to always show table layout regardless of current view
|
||||||
|
- Add "Future" column in kanban: projects with `target_year = 9999` (sentinel value) display as "Future"
|
||||||
|
- Update the form: Target Year select should include a "Future (Beyond 5-Year)" option that maps to year 9999
|
||||||
|
- Kanban year list: always include current year through +5, plus "Future" if any projects exist there
|
||||||
|
- Table view: group "Future" projects under a "Future" header
|
||||||
|
- Title: "Capital Projects" (remove "(5-Year Plan)" since we now have Future)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- No backend changes needed — target_year=9999 works with existing schema (integer column, no constraint)
|
||||||
|
|
||||||
|
## File Change Summary
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `db/init/00-init.sql` | Add contract_number, plan_level, update status CHECK |
|
||||||
|
| `backend/src/modules/organizations/entities/organization.entity.ts` | Add contractNumber, planLevel columns |
|
||||||
|
| `backend/src/modules/organizations/dto/create-organization.dto.ts` | Add contractNumber, planLevel fields |
|
||||||
|
| `backend/src/modules/auth/admin.controller.ts` | Add POST /admin/tenants, PUT /admin/organizations/:id/status |
|
||||||
|
| `backend/src/modules/auth/auth.module.ts` | Import OrganizationsModule |
|
||||||
|
| `backend/src/modules/auth/auth.service.ts` | Add org status check on login |
|
||||||
|
| `backend/src/modules/users/users.service.ts` | Update findAllOrganizations query |
|
||||||
|
| `backend/src/modules/units/units.controller.ts` | Add DELETE route |
|
||||||
|
| `backend/src/modules/units/units.service.ts` | Add delete(), assessment_group_id support |
|
||||||
|
| `backend/src/modules/assessment-groups/assessment-groups.service.ts` | Add frequency support + adjust income calcs |
|
||||||
|
| `backend/src/database/tenant-schema.service.ts` | Add frequency to assessment_groups DDL |
|
||||||
|
| `frontend/src/assets/logo.svg` | New — copy from /Users/claw/Downloads/logo_house.svg |
|
||||||
|
| `frontend/src/components/layout/AppLayout.tsx` | Replace text with logo |
|
||||||
|
| `frontend/src/components/layout/Sidebar.tsx` | Restructure with grouped sections |
|
||||||
|
| `frontend/src/pages/admin/AdminPage.tsx` | Create tenant modal, status management, new columns |
|
||||||
|
| `frontend/src/pages/units/UnitsPage.tsx` | Delete, assessment group dropdown |
|
||||||
|
| `frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx` | Frequency field |
|
||||||
|
| `frontend/src/pages/capital-projects/CapitalProjectsPage.tsx` | Kanban default, table PDF, Future category |
|
||||||
|
| Live DB | ALTER TABLE commands for contract_number, plan_level, frequency, status CHECK |
|
||||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# ---- Production Dockerfile for NestJS backend ----
|
||||||
|
# Multi-stage build: compile TypeScript, then run with minimal image
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Only install production dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy compiled output and New Relic preload from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/newrelic-preload.js ./newrelic-preload.js
|
||||||
|
|
||||||
|
# New Relic agent — configured entirely via environment variables
|
||||||
|
ENV NEW_RELIC_NO_CONFIG_FILE=true
|
||||||
|
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
|
||||||
|
ENV NEW_RELIC_LOG=stdout
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Preload the New Relic agent (activates only when NEW_RELIC_ENABLED=true)
|
||||||
|
CMD ["node", "-r", "./newrelic-preload.js", "dist/main"]
|
||||||
@@ -7,6 +7,11 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# New Relic agent — configured entirely via environment variables
|
||||||
|
ENV NEW_RELIC_NO_CONFIG_FILE=true
|
||||||
|
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
|
||||||
|
ENV NEW_RELIC_LOG=stdout
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["npm", "run", "start:dev"]
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
|||||||
7
backend/newrelic-preload.js
Normal file
7
backend/newrelic-preload.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Conditionally load the New Relic agent before any other modules.
|
||||||
|
// Controlled by the NEW_RELIC_ENABLED environment variable (.env).
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (process.env.NEW_RELIC_ENABLED === 'true') {
|
||||||
|
require('newrelic');
|
||||||
|
}
|
||||||
1431
backend/package-lock.json
generated
1431
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "0.2.0",
|
"version": "2026.3.7-beta",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node -r ./newrelic-preload.js dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
"newrelic": "latest",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
@@ -24,6 +26,8 @@ import { ProjectsModule } from './modules/projects/projects.module';
|
|||||||
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -39,6 +43,13 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
|
|||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
|
// Connection pool — reuse connections instead of creating new ones per query
|
||||||
|
extra: {
|
||||||
|
max: 30, // max pool size (across all concurrent requests)
|
||||||
|
min: 5, // keep at least 5 idle connections warm
|
||||||
|
idleTimeoutMillis: 30000, // close idle connections after 30s
|
||||||
|
connectionTimeoutMillis: 5000, // fail fast if pool is exhausted
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
@@ -62,8 +73,16 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
|
|||||||
MonthlyActualsModule,
|
MonthlyActualsModule,
|
||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
|
HealthScoresModule,
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: WriteAccessGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ALLOW_VIEWER_KEY = 'allowViewer';
|
||||||
|
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);
|
||||||
35
backend/src/common/guards/write-access.guard.ts
Normal file
35
backend/src/common/guards/write-access.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WriteAccessGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// Allow all read methods
|
||||||
|
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
|
||||||
|
|
||||||
|
// Determine role from either req.userRole (set by TenantMiddleware which runs
|
||||||
|
// before guards) or req.user.role (set by JwtAuthGuard Passport strategy).
|
||||||
|
const role = request.userRole || request.user?.role;
|
||||||
|
if (!role) return true; // unauthenticated endpoints like login/register
|
||||||
|
|
||||||
|
// Check for @AllowViewer() exemption on handler or class
|
||||||
|
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (allowViewer) return true;
|
||||||
|
|
||||||
|
// Block viewer role from write operations
|
||||||
|
if (role === 'viewer') {
|
||||||
|
throw new ForbiddenException('Read-only users cannot modify data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -202,6 +207,7 @@ export class TenantSchemaService {
|
|||||||
default_account_id UUID REFERENCES "${s}".accounts(id),
|
default_account_id UUID REFERENCES "${s}".accounts(id),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
||||||
|
last_negotiated DATE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
@@ -316,6 +322,38 @@ export class TenantSchemaService {
|
|||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// AI Investment Recommendations (saved per tenant)
|
||||||
|
`CREATE TABLE "${s}".ai_recommendations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
recommendations_json JSONB NOT NULL,
|
||||||
|
overall_assessment TEXT,
|
||||||
|
risk_notes JSONB,
|
||||||
|
requested_by UUID,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
status VARCHAR(20) DEFAULT 'complete',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Health Scores (AI-derived operating / reserve fund health)
|
||||||
|
`CREATE TABLE "${s}".health_scores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
|
||||||
|
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||||
|
previous_score INTEGER,
|
||||||
|
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
|
||||||
|
label VARCHAR(30),
|
||||||
|
summary TEXT,
|
||||||
|
factors JSONB,
|
||||||
|
recommendations JSONB,
|
||||||
|
missing_data JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
|
||||||
|
|
||||||
// Attachments (file storage for receipts/invoices)
|
// Attachments (file storage for receipts/invoices)
|
||||||
`CREATE TABLE "${s}".attachments (
|
`CREATE TABLE "${s}".attachments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|||||||
@@ -1,18 +1,53 @@
|
|||||||
|
import * as _cluster from 'node:cluster';
|
||||||
|
import * as os from 'node:os';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clustering — fork one worker per CPU core in production
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WORKERS = isProduction
|
||||||
|
? Math.min(os.cpus().length, 4) // cap at 4 workers to stay within DB pool
|
||||||
|
: 1; // single process in dev
|
||||||
|
|
||||||
|
if (WORKERS > 1 && cluster.isPrimary) {
|
||||||
|
console.log(`Primary ${process.pid} forking ${WORKERS} workers ...`);
|
||||||
|
for (let i = 0; i < WORKERS; i++) {
|
||||||
|
cluster.fork();
|
||||||
|
}
|
||||||
|
cluster.on('exit', (worker: any, code: number) => {
|
||||||
|
console.warn(`Worker ${worker.process.pid} exited (code ${code}), restarting ...`);
|
||||||
|
cluster.fork();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NestJS bootstrap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||||
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// Request logging
|
// Request logging — only in development (too noisy / slow for prod)
|
||||||
app.use((req: any, _res: any, next: any) => {
|
if (!isProduction) {
|
||||||
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
app.use((req: any, _res: any, next: any) => {
|
||||||
next();
|
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
||||||
});
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -22,21 +57,22 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CORS — in production nginx handles this; accept all origins behind the proxy
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: ['http://localhost', 'http://localhost:5173'],
|
origin: isProduction ? true : ['http://localhost', 'http://localhost:5173'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Swagger docs — available in all environments
|
||||||
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('0.1.0')
|
.setVersion('2026.3.7')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
console.log('Backend running on port 3000');
|
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
|
||||||
|
|||||||
@@ -142,7 +142,21 @@ export class AccountsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return account;
|
// Auto-set as primary if this is the first asset account for this fund_type
|
||||||
|
if (dto.accountType === 'asset') {
|
||||||
|
const existingPrimary = await this.tenant.query(
|
||||||
|
'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2',
|
||||||
|
[dto.fundType, accountId],
|
||||||
|
);
|
||||||
|
if (!existingPrimary.length) {
|
||||||
|
await this.tenant.query(
|
||||||
|
'UPDATE accounts SET is_primary = true WHERE id = $1',
|
||||||
|
[accountId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findOne(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateAccountDto) {
|
async update(id: string, dto: UpdateAccountDto) {
|
||||||
|
|||||||
@@ -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()');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
@@ -13,6 +14,7 @@ import { RegisterDto } from './dto/register.dto';
|
|||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { SwitchOrgDto } from './dto/switch-org.dto';
|
import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -42,10 +44,21 @@ export class AuthController {
|
|||||||
return this.authService.getProfile(req.user.sub);
|
return this.authService.getProfile(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('intro-seen')
|
||||||
|
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@AllowViewer()
|
||||||
|
async markIntroSeen(@Request() req: any) {
|
||||||
|
await this.authService.markIntroSeen(req.user.sub);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('switch-org')
|
@Post('switch-org')
|
||||||
@ApiOperation({ summary: 'Switch active organization' })
|
@ApiOperation({ summary: 'Switch active organization' })
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@AllowViewer()
|
||||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
|
|||||||
@@ -131,10 +131,15 @@ export class AuthService {
|
|||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
|
settings: membership.organization.settings || {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markIntroSeen(userId: string): Promise<void> {
|
||||||
|
await this.usersService.markIntroSeen(userId);
|
||||||
|
}
|
||||||
|
|
||||||
private async recordLoginHistory(
|
private async recordLoginHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string | null,
|
organizationId: string | null,
|
||||||
@@ -185,6 +190,7 @@ export class AuthService {
|
|||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
isPlatformOwner: user.isPlatformOwner || false,
|
isPlatformOwner: user.isPlatformOwner || false,
|
||||||
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { HealthScoresService } from './health-scores.service';
|
||||||
|
|
||||||
|
@ApiTags('health-scores')
|
||||||
|
@Controller('health-scores')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class HealthScoresController {
|
||||||
|
private readonly logger = new Logger(HealthScoresController.name);
|
||||||
|
|
||||||
|
constructor(private service: HealthScoresService) {}
|
||||||
|
|
||||||
|
@Get('latest')
|
||||||
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
|
getLatest(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
return this.service.getLatestScores(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculate(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
|
||||||
|
// Fire-and-forget — background processing saves results to DB
|
||||||
|
Promise.all([
|
||||||
|
this.service.calculateScore(schema, 'operating'),
|
||||||
|
this.service.calculateScore(schema, 'reserve'),
|
||||||
|
]).catch((err) => {
|
||||||
|
this.logger.error(`Background health score calculation failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Health score calculations started. Results will appear when ready.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate/operating')
|
||||||
|
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculateOperating(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
this.service.calculateScore(schema, 'operating').catch((err) => {
|
||||||
|
this.logger.error(`Background operating score failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Operating fund health score calculation started.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate/reserve')
|
||||||
|
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculateReserve(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
this.service.calculateScore(schema, 'reserve').catch((err) => {
|
||||||
|
this.logger.error(`Background reserve score failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Reserve fund health score calculation started.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthScoresController } from './health-scores.controller';
|
||||||
|
import { HealthScoresService } from './health-scores.service';
|
||||||
|
import { HealthScoresScheduler } from './health-scores.scheduler';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthScoresController],
|
||||||
|
providers: [HealthScoresService, HealthScoresScheduler],
|
||||||
|
})
|
||||||
|
export class HealthScoresModule {}
|
||||||
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { HealthScoresService } from './health-scores.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthScoresScheduler {
|
||||||
|
private readonly logger = new Logger(HealthScoresScheduler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dataSource: DataSource,
|
||||||
|
private healthScoresService: HealthScoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run daily at 2:00 AM — calculate health scores for all active tenants.
|
||||||
|
* Uses DataSource directly to list tenants (no HTTP request context needed).
|
||||||
|
*/
|
||||||
|
@Cron('0 2 * * *')
|
||||||
|
async calculateAllTenantScores() {
|
||||||
|
this.logger.log('Starting daily health score calculation for all tenants...');
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgs = await this.dataSource.query(
|
||||||
|
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Found ${orgs.length} active tenants`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const org of orgs) {
|
||||||
|
try {
|
||||||
|
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
|
||||||
|
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
|
||||||
|
successCount++;
|
||||||
|
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
errorCount++;
|
||||||
|
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
this.logger.log(
|
||||||
|
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Health score scheduler failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1192
backend/src/modules/health-scores/health-scores.service.ts
Normal file
1192
backend/src/modules/health-scores/health-scores.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
@ApiTags('investment-planning')
|
@ApiTags('investment-planning')
|
||||||
@@ -17,14 +18,27 @@ export class InvestmentPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cd-rates')
|
@Get('cd-rates')
|
||||||
@ApiOperation({ summary: 'Get latest CD rates from market data' })
|
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
||||||
getCdRates() {
|
getCdRates() {
|
||||||
return this.service.getCdRates();
|
return this.service.getCdRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('market-rates')
|
||||||
|
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
||||||
|
getMarketRates() {
|
||||||
|
return this.service.getMarketRates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('saved-recommendation')
|
||||||
|
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
||||||
|
getSavedRecommendation() {
|
||||||
|
return this.service.getSavedRecommendation();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
||||||
getRecommendations(@Req() req: any) {
|
@AllowViewer()
|
||||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
triggerRecommendations(@Req() req: any) {
|
||||||
|
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ export interface InvestmentAccount {
|
|||||||
current_value: string;
|
current_value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CdRate {
|
export interface MarketRate {
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
apy: string;
|
apy: string;
|
||||||
min_deposit: string | null;
|
min_deposit: string | null;
|
||||||
term: string;
|
term: string;
|
||||||
term_months: number | null;
|
term_months: number | null;
|
||||||
|
rate_type: string;
|
||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +58,18 @@ export interface AIResponse {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SavedRecommendation {
|
||||||
|
id: string;
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
overall_assessment: string;
|
||||||
|
risk_notes: string[];
|
||||||
|
response_time_ms: number;
|
||||||
|
created_at: string;
|
||||||
|
status: 'processing' | 'complete' | 'error';
|
||||||
|
last_failed: boolean;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvestmentPlanningService {
|
export class InvestmentPlanningService {
|
||||||
private readonly logger = new Logger(InvestmentPlanningService.name);
|
private readonly logger = new Logger(InvestmentPlanningService.name);
|
||||||
@@ -139,40 +152,341 @@ export class InvestmentPlanningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch latest CD rates from the shared schema (cross-tenant market data).
|
* Fetch latest market rates from the shared schema (cross-tenant market data).
|
||||||
* Uses DataSource directly since this queries the shared schema, not tenant.
|
* Returns rates grouped by type, each showing only the most recent fetch batch.
|
||||||
*/
|
*/
|
||||||
async getCdRates(): Promise<CdRate[]> {
|
async getMarketRates(): Promise<{ cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] }> {
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
try {
|
try {
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
const rates = await queryRunner.query(
|
|
||||||
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at
|
// For each rate type, get the latest batch (same fetched_at timestamp)
|
||||||
FROM shared.cd_rates
|
const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
|
||||||
ORDER BY apy DESC
|
return queryRunner.query(
|
||||||
LIMIT 25`,
|
`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
|
||||||
);
|
FROM shared.cd_rates
|
||||||
return rates;
|
WHERE rate_type = $1
|
||||||
|
AND fetched_at = (
|
||||||
|
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
|
||||||
|
)
|
||||||
|
ORDER BY apy DESC
|
||||||
|
LIMIT 25`,
|
||||||
|
[rateType],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [cd, moneyMarket, highYieldSavings] = await Promise.all([
|
||||||
|
fetchLatest('cd'),
|
||||||
|
fetchLatest('money_market'),
|
||||||
|
fetchLatest('high_yield_savings'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cd,
|
||||||
|
money_market: moneyMarket,
|
||||||
|
high_yield_savings: highYieldSavings,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await queryRunner.release();
|
await queryRunner.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible: get only CD rates.
|
||||||
|
*/
|
||||||
|
async getCdRates(): Promise<MarketRate[]> {
|
||||||
|
const rates = await this.getMarketRates();
|
||||||
|
return rates.cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the status/error_message columns exist (for tenants created before this migration).
|
||||||
|
*/
|
||||||
|
private async ensureStatusColumn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.tenant.query(
|
||||||
|
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
|
||||||
|
);
|
||||||
|
await this.tenant.query(
|
||||||
|
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore — column may already exist or table may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest saved AI recommendation for this tenant.
|
||||||
|
* Returns status and last_failed flag for UI state management.
|
||||||
|
*/
|
||||||
|
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
||||||
|
try {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
|
response_time_ms, status, error_message, created_at
|
||||||
|
FROM ai_recommendations
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
const status = row.status || 'complete';
|
||||||
|
|
||||||
|
// If still processing, return processing status
|
||||||
|
if (status === 'processing') {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: '',
|
||||||
|
risk_notes: [],
|
||||||
|
response_time_ms: 0,
|
||||||
|
created_at: row.created_at,
|
||||||
|
status: 'processing',
|
||||||
|
last_failed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If latest attempt failed, return the last successful result with last_failed flag
|
||||||
|
if (status === 'error') {
|
||||||
|
const lastGood = await this.tenant.query(
|
||||||
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
|
response_time_ms, created_at
|
||||||
|
FROM ai_recommendations
|
||||||
|
WHERE status = 'complete'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastGood?.length) {
|
||||||
|
const goodRow = lastGood[0];
|
||||||
|
const recData = goodRow.recommendations_json || {};
|
||||||
|
return {
|
||||||
|
id: goodRow.id,
|
||||||
|
recommendations: recData.recommendations || [],
|
||||||
|
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
|
||||||
|
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
|
||||||
|
response_time_ms: goodRow.response_time_ms || 0,
|
||||||
|
created_at: goodRow.created_at,
|
||||||
|
status: 'complete',
|
||||||
|
last_failed: true,
|
||||||
|
error_message: row.error_message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No previous good result — return error state
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
|
||||||
|
risk_notes: [],
|
||||||
|
response_time_ms: 0,
|
||||||
|
created_at: row.created_at,
|
||||||
|
status: 'error',
|
||||||
|
last_failed: true,
|
||||||
|
error_message: row.error_message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete — return the data normally
|
||||||
|
const recData = row.recommendations_json || {};
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
recommendations: recData.recommendations || [],
|
||||||
|
overall_assessment: row.overall_assessment || recData.overall_assessment || '',
|
||||||
|
risk_notes: row.risk_notes || recData.risk_notes || [],
|
||||||
|
response_time_ms: row.response_time_ms || 0,
|
||||||
|
created_at: row.created_at,
|
||||||
|
status: 'complete',
|
||||||
|
last_failed: false,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
// Table might not exist yet (pre-migration tenants)
|
||||||
|
this.logger.warn(`Could not load saved recommendations: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a 'processing' placeholder record and return its ID.
|
||||||
|
*/
|
||||||
|
private async saveProcessingRecord(userId?: string): Promise<string> {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO ai_recommendations
|
||||||
|
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
|
||||||
|
VALUES ('{}', '', '[]', $1, 'processing')
|
||||||
|
RETURNING id`,
|
||||||
|
[userId || null],
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a processing record with completed results.
|
||||||
|
*/
|
||||||
|
private async updateRecommendationComplete(
|
||||||
|
jobId: string,
|
||||||
|
aiResponse: AIResponse,
|
||||||
|
userId: string | undefined,
|
||||||
|
elapsed: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE ai_recommendations
|
||||||
|
SET recommendations_json = $1,
|
||||||
|
overall_assessment = $2,
|
||||||
|
risk_notes = $3,
|
||||||
|
response_time_ms = $4,
|
||||||
|
status = 'complete'
|
||||||
|
WHERE id = $5`,
|
||||||
|
[
|
||||||
|
JSON.stringify(aiResponse),
|
||||||
|
aiResponse.overall_assessment || '',
|
||||||
|
JSON.stringify(aiResponse.risk_notes || []),
|
||||||
|
elapsed,
|
||||||
|
jobId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a processing record with error status.
|
||||||
|
*/
|
||||||
|
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE ai_recommendations
|
||||||
|
SET status = 'error',
|
||||||
|
error_message = $1
|
||||||
|
WHERE id = $2`,
|
||||||
|
[errorMessage, jobId],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger AI recommendations asynchronously.
|
||||||
|
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
|
||||||
|
* The TenantService instance remains alive via closure reference for the duration of the background work.
|
||||||
|
*/
|
||||||
|
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
|
||||||
|
const jobId = await this.saveProcessingRecord(userId);
|
||||||
|
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
|
||||||
|
|
||||||
|
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
|
||||||
|
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
|
||||||
|
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full AI recommendation pipeline in the background.
|
||||||
|
*/
|
||||||
|
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const [snapshot, allRates, monthlyForecast] = await Promise.all([
|
||||||
|
this.getFinancialSnapshot(),
|
||||||
|
this.getMarketRates(),
|
||||||
|
this.getMonthlyForecast(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.debug('background_snapshot_summary', {
|
||||||
|
job_id: jobId,
|
||||||
|
operating_cash: snapshot.summary.operating_cash,
|
||||||
|
reserve_cash: snapshot.summary.reserve_cash,
|
||||||
|
total_all: snapshot.summary.total_all,
|
||||||
|
investment_accounts: snapshot.investment_accounts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
||||||
|
const aiResponse = await this.callAI(messages);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.debug('background_final_response', {
|
||||||
|
job_id: jobId,
|
||||||
|
recommendation_count: aiResponse.recommendations.length,
|
||||||
|
has_assessment: !!aiResponse.overall_assessment,
|
||||||
|
elapsed_ms: elapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the AI returned a graceful error (empty recommendations with error message)
|
||||||
|
const isGracefulError = aiResponse.recommendations.length === 0 &&
|
||||||
|
(aiResponse.overall_assessment?.includes('Unable to generate') ||
|
||||||
|
aiResponse.overall_assessment?.includes('invalid response'));
|
||||||
|
|
||||||
|
if (isGracefulError) {
|
||||||
|
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
|
||||||
|
} else {
|
||||||
|
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log AI usage (fire-and-forget)
|
||||||
|
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
||||||
|
|
||||||
|
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
|
||||||
|
await this.updateRecommendationError(jobId, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save AI recommendation result to tenant schema.
|
||||||
|
* @deprecated Use triggerAIRecommendations() for async flow instead
|
||||||
|
*/
|
||||||
|
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO ai_recommendations
|
||||||
|
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'complete')`,
|
||||||
|
[
|
||||||
|
JSON.stringify(aiResponse),
|
||||||
|
aiResponse.overall_assessment || '',
|
||||||
|
JSON.stringify(aiResponse.risk_notes || []),
|
||||||
|
userId || null,
|
||||||
|
elapsed,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
// Non-critical — don't let storage failure break recommendations
|
||||||
|
this.logger.warn(`Could not save recommendation: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrate the AI recommendation flow:
|
* Orchestrate the AI recommendation flow:
|
||||||
* 1. Gather all financial data (tenant-scoped)
|
* 1. Gather all financial data (tenant-scoped)
|
||||||
* 2. Fetch CD rates (shared schema)
|
* 2. Fetch all market rates (shared schema)
|
||||||
* 3. Build the prompt with all context
|
* 3. Build the prompt with all context
|
||||||
* 4. Call the AI API
|
* 4. Call the AI API
|
||||||
* 5. Parse and return structured recommendations
|
* 5. Parse and return structured recommendations
|
||||||
|
* 6. Save to tenant storage for future retrieval
|
||||||
*/
|
*/
|
||||||
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
|
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
|
||||||
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const [snapshot, cdRates, monthlyForecast] = await Promise.all([
|
const [snapshot, allRates, monthlyForecast] = await Promise.all([
|
||||||
this.getFinancialSnapshot(),
|
this.getFinancialSnapshot(),
|
||||||
this.getCdRates(),
|
this.getMarketRates(),
|
||||||
this.getMonthlyForecast(),
|
this.getMonthlyForecast(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -183,11 +497,13 @@ export class InvestmentPlanningService {
|
|||||||
investment_accounts: snapshot.investment_accounts.length,
|
investment_accounts: snapshot.investment_accounts.length,
|
||||||
budgets: snapshot.budgets.length,
|
budgets: snapshot.budgets.length,
|
||||||
projects: snapshot.projects.length,
|
projects: snapshot.projects.length,
|
||||||
cd_rates: cdRates.length,
|
cd_rates: allRates.cd.length,
|
||||||
|
money_market_rates: allRates.money_market.length,
|
||||||
|
savings_rates: allRates.high_yield_savings.length,
|
||||||
forecast_months: monthlyForecast.datapoints.length,
|
forecast_months: monthlyForecast.datapoints.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast);
|
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
||||||
const aiResponse = await this.callAI(messages);
|
const aiResponse = await this.callAI(messages);
|
||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
@@ -197,6 +513,9 @@ export class InvestmentPlanningService {
|
|||||||
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save recommendation to tenant storage (fire-and-forget)
|
||||||
|
this.saveRecommendation(aiResponse, userId, elapsed).catch(() => {});
|
||||||
|
|
||||||
// Log AI usage to shared.ai_recommendation_log (fire-and-forget)
|
// Log AI usage to shared.ai_recommendation_log (fire-and-forget)
|
||||||
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
||||||
|
|
||||||
@@ -345,9 +664,6 @@ export class InvestmentPlanningService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a 12-month forward cash flow forecast for the AI.
|
* Build a 12-month forward cash flow forecast for the AI.
|
||||||
* Mirrors the logic from ReportsService.getCashFlowForecast() but streamlined
|
|
||||||
* for AI context. Includes: assessment income schedule (regular + special),
|
|
||||||
* monthly budget income/expenses, investment maturities, and capital project costs.
|
|
||||||
*/
|
*/
|
||||||
private async getMonthlyForecast() {
|
private async getMonthlyForecast() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -551,7 +867,11 @@ export class InvestmentPlanningService {
|
|||||||
|
|
||||||
// ── Private: AI Prompt Construction ──
|
// ── Private: AI Prompt Construction ──
|
||||||
|
|
||||||
private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) {
|
private buildPromptMessages(
|
||||||
|
snapshot: any,
|
||||||
|
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
||||||
|
monthlyForecast: any,
|
||||||
|
) {
|
||||||
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
|
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
@@ -564,8 +884,10 @@ CRITICAL RULES:
|
|||||||
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
|
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
|
||||||
5. Operating funds should remain highly liquid (money market or high-yield savings only).
|
5. Operating funds should remain highly liquid (money market or high-yield savings only).
|
||||||
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
|
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
|
||||||
7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data.
|
7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
|
||||||
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
|
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
|
||||||
|
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
|
||||||
|
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
|
||||||
|
|
||||||
RESPONSE FORMAT:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
|
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
|
||||||
@@ -581,7 +903,7 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
|
|||||||
"suggested_amount": 50000.00,
|
"suggested_amount": 50000.00,
|
||||||
"suggested_term": "12 months",
|
"suggested_term": "12 months",
|
||||||
"suggested_rate": 4.50,
|
"suggested_rate": 4.50,
|
||||||
"bank_name": "Bank name from CD rates (if applicable)",
|
"bank_name": "Bank name from market rates (if applicable)",
|
||||||
"rationale": "Financial reasoning for why this makes sense"
|
"rationale": "Financial reasoning for why this makes sense"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -589,7 +911,7 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
|
|||||||
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||||
}
|
}
|
||||||
|
|
||||||
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`;
|
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||||
|
|
||||||
// Build the data context for the user prompt
|
// Build the data context for the user prompt
|
||||||
const investmentsList = investment_accounts.length === 0
|
const investmentsList = investment_accounts.length === 0
|
||||||
@@ -616,11 +938,18 @@ IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority item
|
|||||||
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
|
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
const cdRateLines = cdRates.length === 0
|
// Format market rates by type
|
||||||
? 'No CD rate data available. Rate fetcher may not have been run yet.'
|
const formatRates = (rates: MarketRate[], typeLabel: string): string => {
|
||||||
: cdRates.map((r: CdRate) =>
|
if (rates.length === 0) return `No ${typeLabel} rate data available. Rate fetcher may not have been run yet.`;
|
||||||
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`,
|
return rates.map((r: MarketRate) => {
|
||||||
).join('\n');
|
const termStr = r.term !== 'N/A' ? ` | Term: ${r.term}` : '';
|
||||||
|
return `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${termStr} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`;
|
||||||
|
}).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cdRateLines = formatRates(allRates.cd, 'CD');
|
||||||
|
const moneyMarketLines = formatRates(allRates.money_market, 'Money Market');
|
||||||
|
const savingsRateLines = formatRates(allRates.high_yield_savings, 'High Yield Savings');
|
||||||
|
|
||||||
// Format assessment schedule showing regular + special
|
// Format assessment schedule showing regular + special
|
||||||
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
|
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
|
||||||
@@ -679,15 +1008,25 @@ ${projectLines}
|
|||||||
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
|
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
|
||||||
${forecastLines}
|
${forecastLines}
|
||||||
|
|
||||||
=== AVAILABLE CD RATES (Market Data) ===
|
=== AVAILABLE MARKET RATES ===
|
||||||
|
|
||||||
|
--- CD Rates ---
|
||||||
${cdRateLines}
|
${cdRateLines}
|
||||||
|
|
||||||
|
--- Money Market Rates ---
|
||||||
|
${moneyMarketLines}
|
||||||
|
|
||||||
|
--- High Yield Savings Rates ---
|
||||||
|
${savingsRateLines}
|
||||||
|
|
||||||
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
|
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
|
||||||
1. Is there excess cash that could earn better returns in CDs?
|
1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
|
||||||
2. Are any current investments maturing soon that need reinvestment planning?
|
2. Are any current investments maturing soon that need reinvestment planning?
|
||||||
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
|
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
|
||||||
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
|
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
|
||||||
5. Are operating and reserve funds properly separated in the investment strategy?`;
|
5. Are operating and reserve funds properly separated in the investment strategy?
|
||||||
|
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
|
||||||
|
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -754,7 +1093,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 180000, // 3 minute timeout
|
timeout: 600000, // 10 minute timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -768,7 +1107,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error(`Request timed out after 180s`));
|
reject(new Error(`Request timed out after 600s`));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export class JournalEntriesService {
|
|||||||
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT je.*,
|
SELECT je.*,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
||||||
|
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.debit), 0)
|
||||||
|
END as total_debit,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
||||||
|
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.credit), 0)
|
||||||
|
END as total_credit,
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'id', jel.id, 'account_id', jel.account_id,
|
'id', jel.id, 'account_id', jel.account_id,
|
||||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
@@ -23,6 +23,13 @@ export class OrganizationsController {
|
|||||||
return this.orgService.findByUser(req.user.sub);
|
return this.orgService.findByUser(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('settings')
|
||||||
|
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||||
|
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||||
|
this.requireTenantAdmin(req);
|
||||||
|
return this.orgService.updateSettings(req.user.orgId, body);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Org Member Management ──
|
// ── Org Member Management ──
|
||||||
|
|
||||||
private requireTenantAdmin(req: any) {
|
private requireTenantAdmin(req: any) {
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export class OrganizationsService {
|
|||||||
return this.orgRepository.save(org);
|
return this.orgRepository.save(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSettings(id: string, settings: Record<string, any>) {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id } });
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
org.settings = { ...(org.settings || {}), ...settings };
|
||||||
|
return this.orgRepository.save(org);
|
||||||
|
}
|
||||||
|
|
||||||
async findByUser(userId: string) {
|
async findByUser(userId: string) {
|
||||||
const memberships = await this.userOrgRepository.find({
|
const memberships = await this.userOrgRepository.find({
|
||||||
where: { userId, isActive: true },
|
where: { userId, isActive: true },
|
||||||
@@ -146,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);
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const projects = await this.tenant.query(
|
const projects = await this.tenant.query(
|
||||||
'SELECT * FROM projects WHERE is_active = true ORDER BY name',
|
'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name',
|
||||||
);
|
);
|
||||||
return this.computeFunding(projects);
|
return this.computeFunding(projects);
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
async findForPlanning() {
|
async findForPlanning() {
|
||||||
const projects = await this.tenant.query(
|
const projects = await this.tenant.query(
|
||||||
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority',
|
'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority',
|
||||||
);
|
);
|
||||||
return this.computeFunding(projects);
|
return this.computeFunding(projects);
|
||||||
}
|
}
|
||||||
@@ -157,6 +157,9 @@ export class ProjectsService {
|
|||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
|
|
||||||
|
// Date columns must be null (not empty string) for PostgreSQL DATE type
|
||||||
|
const dateFields = new Set(['last_replacement_date', 'next_replacement_date', 'planned_date']);
|
||||||
|
|
||||||
// Build dynamic SET clause
|
// Build dynamic SET clause
|
||||||
const fields: [string, string][] = [
|
const fields: [string, string][] = [
|
||||||
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
||||||
@@ -175,7 +178,8 @@ export class ProjectsService {
|
|||||||
for (const [dtoKey, dbCol] of fields) {
|
for (const [dtoKey, dbCol] of fields) {
|
||||||
if (dto[dtoKey] !== undefined) {
|
if (dto[dtoKey] !== undefined) {
|
||||||
sets.push(`${dbCol} = $${idx++}`);
|
sets.push(`${dbCol} = $${idx++}`);
|
||||||
params.push(dto[dtoKey]);
|
const val = dateFields.has(dtoKey) && dto[dtoKey] === '' ? null : dto[dtoKey];
|
||||||
|
params.push(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +280,7 @@ export class ProjectsService {
|
|||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||||
[id, planned_date],
|
[id, planned_date || null],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
getCashFlowSankey(@Query('year') year?: string) {
|
getCashFlowSankey(
|
||||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
@Query('year') year?: string,
|
||||||
|
@Query('source') source?: string,
|
||||||
|
@Query('fundType') fundType?: string,
|
||||||
|
) {
|
||||||
|
return this.reportsService.getCashFlowSankey(
|
||||||
|
parseInt(year || '') || new Date().getFullYear(),
|
||||||
|
source || 'actuals',
|
||||||
|
fundType || 'all',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow')
|
@Get('cash-flow')
|
||||||
@@ -66,4 +74,20 @@ export class ReportsController {
|
|||||||
const mo = Math.min(parseInt(months || '') || 24, 48);
|
const mo = Math.min(parseInt(months || '') || 24, 48);
|
||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('quarterly')
|
||||||
|
getQuarterlyFinancial(
|
||||||
|
@Query('year') year?: string,
|
||||||
|
@Query('quarter') quarter?: string,
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
const defaultYear = now.getFullYear();
|
||||||
|
// Default to last complete quarter
|
||||||
|
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
|
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
|
||||||
|
const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1;
|
||||||
|
const yr = parseInt(year || '') || defaultQYear;
|
||||||
|
const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4);
|
||||||
|
return this.reportsService.getQuarterlyFinancial(yr, q);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ export class ReportsService {
|
|||||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||||
END as balance
|
END as balance
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
AND je.is_posted = true AND je.is_void = false
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.entry_date <= $1
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date <= $1
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
||||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||||
HAVING CASE
|
HAVING CASE
|
||||||
@@ -32,6 +34,71 @@ export class ReportsService {
|
|||||||
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
||||||
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
||||||
|
|
||||||
|
// Compute current year net income (income - expenses) for the fiscal year through as_of date
|
||||||
|
// This balances the accounting equation: Assets = Liabilities + Equity + Net Income
|
||||||
|
const fiscalYearStart = `${asOf.substring(0, 4)}-01-01`;
|
||||||
|
const netIncomeSql = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN a.account_type = 'income'
|
||||||
|
THEN jel.credit - jel.debit ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
|
||||||
|
THEN jel.debit - jel.credit ELSE 0 END), 0) as net_income
|
||||||
|
FROM journal_entry_lines jel
|
||||||
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date BETWEEN $1 AND $2
|
||||||
|
INNER JOIN accounts a ON a.id = jel.account_id
|
||||||
|
AND a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||||
|
`;
|
||||||
|
const netIncomeResult = await this.tenant.query(netIncomeSql, [fiscalYearStart, asOf]);
|
||||||
|
const netIncome = parseFloat(netIncomeResult[0]?.net_income || '0');
|
||||||
|
|
||||||
|
// Add current year net income as a synthetic equity line
|
||||||
|
if (netIncome !== 0) {
|
||||||
|
equity.push({
|
||||||
|
id: null,
|
||||||
|
account_number: '',
|
||||||
|
name: 'Current Year Net Income',
|
||||||
|
account_type: 'equity',
|
||||||
|
fund_type: 'operating',
|
||||||
|
balance: netIncome.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add investment account balances to assets and corresponding equity
|
||||||
|
const investmentsSql = `
|
||||||
|
SELECT id, name, institution, current_value as balance, fund_type
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true AND current_value > 0
|
||||||
|
`;
|
||||||
|
const investments = await this.tenant.query(investmentsSql);
|
||||||
|
const investmentsByFund: Record<string, number> = {};
|
||||||
|
for (const inv of investments) {
|
||||||
|
assets.push({
|
||||||
|
id: inv.id,
|
||||||
|
account_number: '',
|
||||||
|
name: `${inv.name} (${inv.institution})`,
|
||||||
|
account_type: 'asset',
|
||||||
|
fund_type: inv.fund_type,
|
||||||
|
balance: parseFloat(inv.balance).toFixed(2),
|
||||||
|
});
|
||||||
|
investmentsByFund[inv.fund_type] = (investmentsByFund[inv.fund_type] || 0) + parseFloat(inv.balance);
|
||||||
|
}
|
||||||
|
// Add investment balances as synthetic equity lines to maintain A = L + E
|
||||||
|
for (const [fundType, total] of Object.entries(investmentsByFund)) {
|
||||||
|
if (total > 0) {
|
||||||
|
const label = fundType === 'reserve' ? 'Reserve' : 'Operating';
|
||||||
|
equity.push({
|
||||||
|
id: null,
|
||||||
|
account_number: '',
|
||||||
|
name: `${label} Investment Holdings`,
|
||||||
|
account_type: 'equity',
|
||||||
|
fund_type: fundType,
|
||||||
|
balance: total.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||||
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||||
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||||
@@ -54,10 +121,12 @@ export class ReportsService {
|
|||||||
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
END as amount
|
END as amount
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
AND je.is_posted = true AND je.is_void = false
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.entry_date BETWEEN $1 AND $2
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date BETWEEN $1 AND $2
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
|
WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
|
||||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||||
HAVING CASE
|
HAVING CASE
|
||||||
@@ -83,33 +152,151 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCashFlowSankey(year: number) {
|
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
|
||||||
// Get income accounts with amounts
|
let income: any[];
|
||||||
const income = await this.tenant.query(`
|
let expenses: any[];
|
||||||
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
|
||||||
FROM accounts a
|
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
|
||||||
AND je.is_posted = true AND je.is_void = false
|
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
|
||||||
WHERE a.account_type = 'income' AND a.is_active = true
|
|
||||||
GROUP BY a.id, a.name
|
|
||||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
|
||||||
ORDER BY amount DESC
|
|
||||||
`, [year]);
|
|
||||||
|
|
||||||
const expenses = await this.tenant.query(`
|
const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
|
||||||
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
const fundParams = fundType !== 'all' ? [year, fundType] : [year];
|
||||||
FROM accounts a
|
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`;
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
|
||||||
AND je.is_posted = true AND je.is_void = false
|
if (source === 'budget') {
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
income = await this.tenant.query(`
|
||||||
WHERE a.account_type = 'expense' AND a.is_active = true
|
SELECT a.name, SUM(${monthSum}) as amount
|
||||||
GROUP BY a.id, a.name, a.fund_type
|
FROM budgets b
|
||||||
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
JOIN accounts a ON a.id = b.account_id
|
||||||
ORDER BY amount DESC
|
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||||
`, [year]);
|
GROUP BY a.id, a.name
|
||||||
|
HAVING SUM(${monthSum}) > 0
|
||||||
|
ORDER BY SUM(${monthSum}) DESC
|
||||||
|
`, fundParams);
|
||||||
|
|
||||||
|
expenses = await this.tenant.query(`
|
||||||
|
SELECT a.name, a.fund_type, SUM(${monthSum}) as amount
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||||
|
GROUP BY a.id, a.name, a.fund_type
|
||||||
|
HAVING SUM(${monthSum}) > 0
|
||||||
|
ORDER BY SUM(${monthSum}) DESC
|
||||||
|
`, fundParams);
|
||||||
|
|
||||||
|
} else if (source === 'forecast') {
|
||||||
|
// Combine actuals (Jan to current date) + budget (remaining months)
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = now.getMonth(); // 0-indexed
|
||||||
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
|
const remainingMonths = monthNames.slice(currentMonth + 1);
|
||||||
|
|
||||||
|
const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : '';
|
||||||
|
const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`];
|
||||||
|
|
||||||
|
const actualsIncome = await this.tenant.query(`
|
||||||
|
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
|
||||||
|
WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond}
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
`, actualsParams);
|
||||||
|
|
||||||
|
const actualsExpenses = await this.tenant.query(`
|
||||||
|
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
|
||||||
|
WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond}
|
||||||
|
GROUP BY a.id, a.name, a.fund_type
|
||||||
|
`, actualsParams);
|
||||||
|
|
||||||
|
// Budget for remaining months
|
||||||
|
let budgetIncome: any[] = [];
|
||||||
|
let budgetExpenses: any[] = [];
|
||||||
|
if (remainingMonths.length > 0) {
|
||||||
|
const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+');
|
||||||
|
budgetIncome = await this.tenant.query(`
|
||||||
|
SELECT a.name, SUM(${budgetMonthSum}) as amount
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
`, fundParams);
|
||||||
|
|
||||||
|
budgetExpenses = await this.tenant.query(`
|
||||||
|
SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||||
|
GROUP BY a.id, a.name, a.fund_type
|
||||||
|
`, fundParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge actuals + budget by account name
|
||||||
|
const incomeMap = new Map<string, number>();
|
||||||
|
for (const a of actualsIncome) {
|
||||||
|
const amt = parseFloat(a.amount) || 0;
|
||||||
|
if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt);
|
||||||
|
}
|
||||||
|
for (const b of budgetIncome) {
|
||||||
|
const amt = parseFloat(b.amount) || 0;
|
||||||
|
if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt);
|
||||||
|
}
|
||||||
|
income = Array.from(incomeMap.entries())
|
||||||
|
.map(([name, amount]) => ({ name, amount: String(amount) }))
|
||||||
|
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
|
||||||
|
|
||||||
|
const expenseMap = new Map<string, { amount: number; fund_type: string }>();
|
||||||
|
for (const a of actualsExpenses) {
|
||||||
|
const amt = parseFloat(a.amount) || 0;
|
||||||
|
if (amt > 0) {
|
||||||
|
const existing = expenseMap.get(a.name);
|
||||||
|
expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const b of budgetExpenses) {
|
||||||
|
const amt = parseFloat(b.amount) || 0;
|
||||||
|
if (amt > 0) {
|
||||||
|
const existing = expenseMap.get(b.name);
|
||||||
|
expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expenses = Array.from(expenseMap.entries())
|
||||||
|
.map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type }))
|
||||||
|
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Actuals: query journal entries for the year
|
||||||
|
income = await this.tenant.query(`
|
||||||
|
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||||
|
ORDER BY amount DESC
|
||||||
|
`, fundParams);
|
||||||
|
|
||||||
|
expenses = await this.tenant.query(`
|
||||||
|
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||||
|
GROUP BY a.id, a.name, a.fund_type
|
||||||
|
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
||||||
|
ORDER BY amount DESC
|
||||||
|
`, fundParams);
|
||||||
|
}
|
||||||
|
|
||||||
if (!income.length && !expenses.length) {
|
if (!income.length && !expenses.length) {
|
||||||
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
||||||
@@ -222,20 +409,20 @@ export class ReportsService {
|
|||||||
ORDER BY a.name
|
ORDER BY a.name
|
||||||
`, [from, to]);
|
`, [from, to]);
|
||||||
|
|
||||||
// Asset filter: cash-only vs cash + investment accounts
|
// Asset filter: all asset accounts (bank/checking/savings are the cash accounts)
|
||||||
const assetFilter = includeInvestments
|
const assetFilter = `a.account_type = 'asset'`;
|
||||||
? `a.account_type = 'asset'`
|
|
||||||
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
|
|
||||||
|
|
||||||
// Cash beginning and ending balances
|
// Cash beginning and ending balances
|
||||||
const beginCash = await this.tenant.query(`
|
const beginCash = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
AND je.is_posted = true AND je.is_void = false
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.entry_date < $1
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date < $1
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE ${assetFilter} AND a.is_active = true
|
WHERE ${assetFilter} AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
@@ -245,10 +432,12 @@ export class ReportsService {
|
|||||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
AND je.is_posted = true AND je.is_void = false
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.entry_date <= $1
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date <= $1
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE ${assetFilter} AND a.is_active = true
|
WHERE ${assetFilter} AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
@@ -273,7 +462,8 @@ export class ReportsService {
|
|||||||
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
|
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
|
||||||
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance;
|
// Only include investment balances in ending balance when includeInvestments is toggled on
|
||||||
|
const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from, to,
|
from, to,
|
||||||
@@ -360,19 +550,22 @@ export class ReportsService {
|
|||||||
const incomeStmt = await this.getIncomeStatement(from, to);
|
const incomeStmt = await this.getIncomeStatement(from, to);
|
||||||
const balanceSheet = await this.getBalanceSheet(to);
|
const balanceSheet = await this.getBalanceSheet(to);
|
||||||
|
|
||||||
// 1099 vendor data
|
// 1099 vendor data — uses journal entries via vendor's default_account_id
|
||||||
const vendors1099 = await this.tenant.query(`
|
const vendors1099 = await this.tenant.query(`
|
||||||
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
||||||
COALESCE(SUM(p.amount), 0) as total_paid
|
COALESCE(SUM(p_amounts.amount), 0) as total_paid
|
||||||
FROM vendors v
|
FROM vendors v
|
||||||
JOIN (
|
LEFT JOIN (
|
||||||
SELECT vendor_id, amount FROM invoices
|
SELECT jel.account_id, jel.debit as amount
|
||||||
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
FROM journal_entry_lines jel
|
||||||
AND status IN ('paid', 'partial')
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
) p ON p.vendor_id = v.id
|
WHERE je.is_posted = true AND je.is_void = false
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
AND jel.debit > 0
|
||||||
|
) p_amounts ON p_amounts.account_id = v.default_account_id
|
||||||
WHERE v.is_1099_eligible = true
|
WHERE v.is_1099_eligible = true
|
||||||
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
||||||
HAVING COALESCE(SUM(p.amount), 0) >= 600
|
HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
|
||||||
ORDER BY v.name
|
ORDER BY v.name
|
||||||
`, [year]);
|
`, [year]);
|
||||||
|
|
||||||
@@ -444,24 +637,43 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDashboardKPIs() {
|
async getDashboardKPIs() {
|
||||||
// Total cash: ALL asset accounts (not just those named "Cash")
|
// Operating cash (asset accounts, fund_type=operating)
|
||||||
// Uses proper double-entry balance: debit - credit for assets
|
const opCash = await this.tenant.query(`
|
||||||
const cash = await this.tenant.query(`
|
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
WHERE a.account_type = 'asset' AND a.is_active = true
|
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
`);
|
`);
|
||||||
// Also include investment account current_value in total cash
|
// Reserve cash (asset accounts, fund_type=reserve)
|
||||||
const investmentCash = await this.tenant.query(`
|
const resCash = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(current_value), 0) as total
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
FROM investment_accounts WHERE is_active = true
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
`);
|
`);
|
||||||
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0');
|
// Investment accounts split by fund type
|
||||||
|
const opInv = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||||
|
`);
|
||||||
|
const resInv = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
const operatingCash = parseFloat(opCash[0]?.total || '0');
|
||||||
|
const reserveCash = parseFloat(resCash[0]?.total || '0');
|
||||||
|
const operatingInvestments = parseFloat(opInv[0]?.total || '0');
|
||||||
|
const reserveInvestments = parseFloat(resInv[0]?.total || '0');
|
||||||
|
const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments;
|
||||||
|
|
||||||
// Receivables: sum of unpaid invoices
|
// Receivables: sum of unpaid invoices
|
||||||
const ar = await this.tenant.query(`
|
const ar = await this.tenant.query(`
|
||||||
@@ -469,9 +681,7 @@ export class ReportsService {
|
|||||||
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
|
// Reserve fund balance via equity accounts + reserve investments
|
||||||
// The equity accounts track the total reserve fund position via double-entry bookkeeping
|
|
||||||
// This is the standard HOA approach — every reserve contribution/expenditure flows through equity
|
|
||||||
const reserves = await this.tenant.query(`
|
const reserves = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
||||||
@@ -482,17 +692,43 @@ export class ReportsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
`);
|
`);
|
||||||
// Add reserve investment account values to the reserve fund total
|
|
||||||
const reserveInvestments = await this.tenant.query(`
|
|
||||||
SELECT COALESCE(SUM(current_value), 0) as total
|
|
||||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Delinquent count (overdue invoices)
|
// Delinquent count (overdue invoices)
|
||||||
const delinquent = await this.tenant.query(`
|
const delinquent = await this.tenant.query(`
|
||||||
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Monthly interest estimate from accounts + investments with rates
|
||||||
|
const acctInterest = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.monthly_interest), 0) as total FROM (
|
||||||
|
SELECT (COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12 as monthly_interest
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.is_active = true AND a.interest_rate > 0
|
||||||
|
GROUP BY a.id, a.interest_rate
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
const acctInterestTotal = parseFloat(acctInterest[0]?.total || '0');
|
||||||
|
const invInterest = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total
|
||||||
|
FROM investment_accounts WHERE is_active = true AND interest_rate > 0
|
||||||
|
`);
|
||||||
|
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
||||||
|
|
||||||
|
// Interest earned YTD: approximate from current_value - principal (unrealized gains)
|
||||||
|
const interestEarned = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value - principal), 0) as total
|
||||||
|
FROM investment_accounts WHERE is_active = true AND current_value > principal
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Planned capital spend for current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const capitalSpend = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
|
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
||||||
|
`, [currentYear]);
|
||||||
|
|
||||||
// Recent transactions
|
// Recent transactions
|
||||||
const recentTx = await this.tenant.query(`
|
const recentTx = await this.tenant.query(`
|
||||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||||
@@ -504,9 +740,17 @@ export class ReportsService {
|
|||||||
return {
|
return {
|
||||||
total_cash: totalCash.toFixed(2),
|
total_cash: totalCash.toFixed(2),
|
||||||
total_receivables: ar[0]?.total || '0.00',
|
total_receivables: ar[0]?.total || '0.00',
|
||||||
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2),
|
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2),
|
||||||
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||||
recent_transactions: recentTx,
|
recent_transactions: recentTx,
|
||||||
|
// Enhanced split data
|
||||||
|
operating_cash: operatingCash.toFixed(2),
|
||||||
|
reserve_cash: reserveCash.toFixed(2),
|
||||||
|
operating_investments: operatingInvestments.toFixed(2),
|
||||||
|
reserve_investments: reserveInvestments.toFixed(2),
|
||||||
|
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
||||||
|
interest_earned_ytd: interestEarned[0]?.total || '0.00',
|
||||||
|
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,4 +1039,168 @@ export class ReportsService {
|
|||||||
datapoints,
|
datapoints,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quarterly Financial Report: quarter income statement, YTD income statement,
|
||||||
|
* budget vs actuals for the quarter and YTD, and over-budget items.
|
||||||
|
*/
|
||||||
|
async getQuarterlyFinancial(year: number, quarter: number) {
|
||||||
|
// Quarter date ranges
|
||||||
|
const qStartMonths = [1, 4, 7, 10];
|
||||||
|
const qEndMonths = [3, 6, 9, 12];
|
||||||
|
const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`;
|
||||||
|
const qEndMonth = qEndMonths[quarter - 1];
|
||||||
|
const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31
|
||||||
|
const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`;
|
||||||
|
const ytdStart = `${year}-01-01`;
|
||||||
|
|
||||||
|
// Quarter and YTD income statements (reuse existing method)
|
||||||
|
const quarterIS = await this.getIncomeStatement(qStart, qEnd);
|
||||||
|
const ytdIS = await this.getIncomeStatement(ytdStart, qEnd);
|
||||||
|
|
||||||
|
// Budget data for the quarter months
|
||||||
|
const budgetMonthCols = {
|
||||||
|
1: ['jan', 'feb', 'mar'],
|
||||||
|
2: ['apr', 'may', 'jun'],
|
||||||
|
3: ['jul', 'aug', 'sep'],
|
||||||
|
4: ['oct', 'nov', 'dec_amt'],
|
||||||
|
} as Record<number, string[]>;
|
||||||
|
const ytdMonthCols = {
|
||||||
|
1: ['jan', 'feb', 'mar'],
|
||||||
|
2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'],
|
||||||
|
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'],
|
||||||
|
4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'],
|
||||||
|
} as Record<number, string[]>;
|
||||||
|
|
||||||
|
const qCols = budgetMonthCols[quarter];
|
||||||
|
const ytdCols = ytdMonthCols[quarter];
|
||||||
|
|
||||||
|
const budgetRows = await this.tenant.query(
|
||||||
|
`SELECT b.account_id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||||
|
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
||||||
|
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1`, [year],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actual amounts per account for the quarter and YTD
|
||||||
|
const quarterActuals = await this.tenant.query(`
|
||||||
|
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||||
|
CASE
|
||||||
|
WHEN a.account_type = 'income'
|
||||||
|
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
|
END as amount
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date BETWEEN $1 AND $2
|
||||||
|
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||||
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||||
|
`, [qStart, qEnd]);
|
||||||
|
|
||||||
|
const ytdActuals = await this.tenant.query(`
|
||||||
|
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||||
|
CASE
|
||||||
|
WHEN a.account_type = 'income'
|
||||||
|
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
|
END as amount
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date BETWEEN $1 AND $2
|
||||||
|
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||||
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||||
|
`, [ytdStart, qEnd]);
|
||||||
|
|
||||||
|
// Build budget vs actual comparison
|
||||||
|
const actualsByIdQ = new Map<string, number>();
|
||||||
|
for (const a of quarterActuals) {
|
||||||
|
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
|
||||||
|
}
|
||||||
|
const actualsByIdYTD = new Map<string, number>();
|
||||||
|
for (const a of ytdActuals) {
|
||||||
|
actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetVsActual: any[] = [];
|
||||||
|
const overBudgetItems: any[] = [];
|
||||||
|
|
||||||
|
for (const b of budgetRows) {
|
||||||
|
const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
|
||||||
|
const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
|
||||||
|
const qActual = actualsByIdQ.get(b.account_id) || 0;
|
||||||
|
const ytdActual = actualsByIdYTD.get(b.account_id) || 0;
|
||||||
|
|
||||||
|
if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue;
|
||||||
|
|
||||||
|
const qVariance = qActual - qBudget;
|
||||||
|
const ytdVariance = ytdActual - ytdBudget;
|
||||||
|
const isExpense = b.account_type === 'expense';
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
account_id: b.account_id,
|
||||||
|
account_number: b.account_number,
|
||||||
|
name: b.name,
|
||||||
|
account_type: b.account_type,
|
||||||
|
fund_type: b.fund_type,
|
||||||
|
quarter_budget: qBudget,
|
||||||
|
quarter_actual: qActual,
|
||||||
|
quarter_variance: qVariance,
|
||||||
|
ytd_budget: ytdBudget,
|
||||||
|
ytd_actual: ytdActual,
|
||||||
|
ytd_variance: ytdVariance,
|
||||||
|
};
|
||||||
|
budgetVsActual.push(item);
|
||||||
|
|
||||||
|
// Flag expenses over budget by more than 10%
|
||||||
|
if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) {
|
||||||
|
overBudgetItems.push({
|
||||||
|
...item,
|
||||||
|
variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include accounts with actuals but no budget
|
||||||
|
for (const a of quarterActuals) {
|
||||||
|
if (!budgetRows.find((b: any) => b.account_id === a.account_id)) {
|
||||||
|
const ytdActual = actualsByIdYTD.get(a.account_id) || 0;
|
||||||
|
budgetVsActual.push({
|
||||||
|
account_id: a.account_id,
|
||||||
|
account_number: a.account_number,
|
||||||
|
name: a.name,
|
||||||
|
account_type: a.account_type,
|
||||||
|
fund_type: a.fund_type,
|
||||||
|
quarter_budget: 0,
|
||||||
|
quarter_actual: parseFloat(a.amount) || 0,
|
||||||
|
quarter_variance: parseFloat(a.amount) || 0,
|
||||||
|
ytd_budget: 0,
|
||||||
|
ytd_actual: ytdActual,
|
||||||
|
ytd_variance: ytdActual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: income first, then expenses, both by account number
|
||||||
|
budgetVsActual.sort((a: any, b: any) => {
|
||||||
|
if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1;
|
||||||
|
return (a.account_number || '').localeCompare(b.account_number || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
quarter,
|
||||||
|
quarter_label: `Q${quarter} ${year}`,
|
||||||
|
date_range: { from: qStart, to: qEnd },
|
||||||
|
quarter_income_statement: quarterIS,
|
||||||
|
ytd_income_statement: ytdIS,
|
||||||
|
budget_vs_actual: budgetVsActual,
|
||||||
|
over_budget_items: overBudgetItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export class User {
|
|||||||
@Column({ name: 'is_platform_owner', default: false })
|
@Column({ name: 'is_platform_owner', default: false })
|
||||||
isPlatformOwner: boolean;
|
isPlatformOwner: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'has_seen_intro', default: false })
|
||||||
|
hasSeenIntro: boolean;
|
||||||
|
|
||||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export class UsersService {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markIntroSeen(id: string): Promise<void> {
|
||||||
|
await this.usersRepository.update(id, { hasSeenIntro: true });
|
||||||
|
}
|
||||||
|
|
||||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||||
// Protect platform owner from having superadmin removed
|
// Protect platform owner from having superadmin removed
|
||||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||||
|
|||||||
27
backend/src/modules/vendors/vendors.service.ts
vendored
27
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -17,10 +17,10 @@ export class VendorsService {
|
|||||||
|
|
||||||
async create(dto: any) {
|
async create(dto: any) {
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id)
|
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||||
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
|
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
|
||||||
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null],
|
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -32,24 +32,25 @@ export class VendorsService {
|
|||||||
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
|
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
|
||||||
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
|
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
|
||||||
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
|
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
|
||||||
default_account_id = COALESCE($12, default_account_id), updated_at = NOW()
|
default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, updated_at = NOW()
|
||||||
WHERE id = $1 RETURNING *`,
|
WHERE id = $1 RETURNING *`,
|
||||||
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
|
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
|
||||||
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id],
|
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportCSV(): Promise<string> {
|
async exportCSV(): Promise<string> {
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible
|
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated
|
||||||
FROM vendors WHERE is_active = true ORDER BY name`,
|
FROM vendors WHERE is_active = true ORDER BY name`,
|
||||||
);
|
);
|
||||||
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible'];
|
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated'];
|
||||||
const lines = [headers.join(',')];
|
const lines = [headers.join(',')];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
lines.push(headers.map((h) => {
|
lines.push(headers.map((h) => {
|
||||||
const v = r[h] ?? '';
|
let v = r[h] ?? '';
|
||||||
|
if (v instanceof Date) v = v.toISOString().split('T')[0];
|
||||||
const s = String(v);
|
const s = String(v);
|
||||||
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
}).join(','));
|
}).join(','));
|
||||||
@@ -80,20 +81,22 @@ export class VendorsService {
|
|||||||
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
||||||
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
||||||
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
||||||
|
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
|
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
|
||||||
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible],
|
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated],
|
||||||
);
|
);
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
|
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
[name, row.contact_name || null, row.email || null, row.phone || null,
|
[name, row.contact_name || null, row.email || null, row.phone || null,
|
||||||
row.address_line1 || null, row.city || null, row.state || null,
|
row.address_line1 || null, row.city || null, row.state || null,
|
||||||
row.zip_code || null, row.tax_id || null,
|
row.zip_code || null, row.tax_id || null,
|
||||||
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false],
|
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false,
|
||||||
|
row.last_negotiated || null],
|
||||||
);
|
);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ CREATE TABLE shared.invitations (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CD Rates (cross-tenant market data for investment recommendations)
|
-- Market Rates (cross-tenant market data for investment recommendations)
|
||||||
|
-- Supports CD, Money Market, and High Yield Savings rate types
|
||||||
CREATE TABLE shared.cd_rates (
|
CREATE TABLE shared.cd_rates (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
bank_name VARCHAR(255) NOT NULL,
|
bank_name VARCHAR(255) NOT NULL,
|
||||||
@@ -85,6 +86,7 @@ CREATE TABLE shared.cd_rates (
|
|||||||
min_deposit DECIMAL(15,2),
|
min_deposit DECIMAL(15,2),
|
||||||
term VARCHAR(100) NOT NULL,
|
term VARCHAR(100) NOT NULL,
|
||||||
term_months INTEGER,
|
term_months INTEGER,
|
||||||
|
rate_type VARCHAR(50) NOT NULL DEFAULT 'cd',
|
||||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
source_url VARCHAR(500),
|
source_url VARCHAR(500),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
@@ -121,6 +123,8 @@ CREATE INDEX idx_invitations_token ON shared.invitations(token);
|
|||||||
CREATE INDEX idx_invitations_email ON shared.invitations(email);
|
CREATE INDEX idx_invitations_email ON shared.invitations(email);
|
||||||
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
||||||
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
||||||
|
CREATE INDEX idx_cd_rates_type ON shared.cd_rates(rate_type);
|
||||||
|
CREATE INDEX idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
|
||||||
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
|
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
|
||||||
CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
|
CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
|
||||||
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);
|
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);
|
||||||
|
|||||||
36
db/migrations/007-market-rates.sql
Normal file
36
db/migrations/007-market-rates.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Migration: Expand cd_rates for multiple market rate types + tenant AI recommendation storage
|
||||||
|
-- Phase 6: AI Features Part 2
|
||||||
|
|
||||||
|
-- 1) Add rate_type column to shared.cd_rates to support CD, Money Market, and High Yield Savings
|
||||||
|
ALTER TABLE shared.cd_rates
|
||||||
|
ADD COLUMN IF NOT EXISTS rate_type VARCHAR(50) DEFAULT 'cd' NOT NULL;
|
||||||
|
|
||||||
|
-- Index for filtering by rate type
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cd_rates_type ON shared.cd_rates(rate_type);
|
||||||
|
|
||||||
|
-- Composite index for getting latest rates by type efficiently
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
|
||||||
|
|
||||||
|
-- 2) Create ai_recommendations table in each existing tenant schema
|
||||||
|
-- This stores saved AI investment recommendations per tenant
|
||||||
|
-- For new tenants, this is handled by tenant-schema.service.ts
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tenant_schema TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR tenant_schema IN
|
||||||
|
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TABLE IF NOT EXISTS %I.ai_recommendations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
recommendations_json JSONB NOT NULL,
|
||||||
|
overall_assessment TEXT,
|
||||||
|
risk_notes JSONB,
|
||||||
|
requested_by UUID,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', tenant_schema
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Add last_negotiated date to vendors table
|
||||||
|
-- Bug & Tweak Sprint
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tenant_schema TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR tenant_schema IN
|
||||||
|
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE',
|
||||||
|
tenant_schema
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
9
db/migrations/009-onboarding-flags.sql
Normal file
9
db/migrations/009-onboarding-flags.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Add onboarding tracking flag to users table
|
||||||
|
-- Phase 7: Onboarding Features
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE shared.users
|
||||||
|
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
34
db/migrations/010-health-scores.sql
Normal file
34
db/migrations/010-health-scores.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Migration: Add health_scores table to all tenant schemas
|
||||||
|
-- This table stores AI-derived operating and reserve fund health scores
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tenant RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR tenant IN
|
||||||
|
SELECT schema_name FROM shared.organizations WHERE status = 'active'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TABLE IF NOT EXISTS %I.health_scores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
score_type VARCHAR(20) NOT NULL CHECK (score_type IN (''operating'', ''reserve'')),
|
||||||
|
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||||
|
previous_score INTEGER,
|
||||||
|
trajectory VARCHAR(20) CHECK (trajectory IN (''improving'', ''stable'', ''declining'')),
|
||||||
|
label VARCHAR(30),
|
||||||
|
summary TEXT,
|
||||||
|
factors JSONB,
|
||||||
|
recommendations JSONB,
|
||||||
|
missing_data JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT ''complete'' CHECK (status IN (''complete'', ''pending'', ''error'')),
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', tenant.schema_name
|
||||||
|
);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)',
|
||||||
|
replace(tenant.schema_name, '.', '_'), tenant.schema_name
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
57
db/migrations/011-invoice-billing-frequency.sql
Normal file
57
db/migrations/011-invoice-billing-frequency.sql
Normal 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 $$;
|
||||||
33
db/migrations/012-invoice-status-pending.sql
Normal file
33
db/migrations/012-invoice-status-pending.sql
Normal 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 $$;
|
||||||
@@ -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);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
95
docker-compose.prod.yml
Normal file
95
docker-compose.prod.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Production override — use with:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
#
|
||||||
|
# What this changes from the base (dev) config:
|
||||||
|
# - Disables the Docker nginx container (host nginx handles routing + SSL)
|
||||||
|
# - Backend: production Dockerfile (compiled JS, no watch, no devDeps)
|
||||||
|
# - Frontend: production Dockerfile (static build served by nginx on port 3001)
|
||||||
|
# - Backend + Frontend bound to 127.0.0.1 only (host nginx proxies to them)
|
||||||
|
# - No source-code volume mounts (uses baked-in built code)
|
||||||
|
# - Memory limits and health checks on backend
|
||||||
|
# - Tuned PostgreSQL for production workloads
|
||||||
|
# - Restart policies for reliability
|
||||||
|
#
|
||||||
|
# SSL/TLS and request routing are handled by the host-level nginx.
|
||||||
|
# See nginx/host-production.conf for a ready-to-use reference config.
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
# Disabled in production — host nginx handles routing + SSL directly.
|
||||||
|
# The dev-only Docker nginx is still used by the base docker-compose.yml.
|
||||||
|
deploy:
|
||||||
|
replicas: 0
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile # production Dockerfile (compiled JS)
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000" # loopback only — host nginx proxies here
|
||||||
|
volumes: [] # override: no source mounts in prod
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- NODE_ENV=production
|
||||||
|
- AI_API_URL=${AI_API_URL}
|
||||||
|
- AI_API_KEY=${AI_API_KEY}
|
||||||
|
- AI_MODEL=${AI_MODEL}
|
||||||
|
- AI_DEBUG=${AI_DEBUG:-false}
|
||||||
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1024M
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile # production Dockerfile (static nginx)
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001" # loopback only — host nginx proxies here
|
||||||
|
volumes: [] # override: no source mounts in prod
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
# Tune PostgreSQL for production workloads
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c max_connections=200
|
||||||
|
-c shared_buffers=256MB
|
||||||
|
-c effective_cache_size=512MB
|
||||||
|
-c work_mem=4MB
|
||||||
|
-c maintenance_work_mem=64MB
|
||||||
|
-c checkpoint_completion_target=0.9
|
||||||
|
-c wal_buffers=16MB
|
||||||
|
-c random_page_cost=1.1
|
||||||
|
# No host port mapping — backend reaches postgres via the Docker network.
|
||||||
|
# Removes 2 docker-proxy processes and closes 0.0.0.0:5432 to the internet.
|
||||||
|
ports: []
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1024M
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# No host port mapping — backend reaches redis via the Docker network.
|
||||||
|
# Removes 2 docker-proxy processes and closes 0.0.0.0:6379 to the internet.
|
||||||
|
ports: []
|
||||||
|
restart: unless-stopped
|
||||||
28
docker-compose.ssl.yml
Normal file
28
docker-compose.ssl.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# SSL override — use with: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d
|
||||||
|
#
|
||||||
|
# This adds port 443, certbot volumes, and a certbot renewal service
|
||||||
|
# to the base docker-compose.yml configuration.
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- certbot_www:/var/www/certbot:ro
|
||||||
|
- certbot_conf:/etc/letsencrypt:ro
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot:latest
|
||||||
|
volumes:
|
||||||
|
- certbot_www:/var/www/certbot
|
||||||
|
- certbot_conf:/etc/letsencrypt
|
||||||
|
networks:
|
||||||
|
- hoanet
|
||||||
|
# Auto-renew: check twice daily, only renews if < 30 days remain
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done'"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
certbot_www:
|
||||||
|
certbot_conf:
|
||||||
@@ -15,8 +15,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
# No host port mapping — dev traffic goes through the Docker nginx container.
|
||||||
- "3000:3000"
|
# Production overlay maps 127.0.0.1:3000 for the host reverse proxy.
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
@@ -26,6 +26,9 @@ services:
|
|||||||
- AI_API_KEY=${AI_API_KEY}
|
- AI_API_KEY=${AI_API_KEY}
|
||||||
- AI_MODEL=${AI_MODEL}
|
- AI_MODEL=${AI_MODEL}
|
||||||
- AI_DEBUG=${AI_DEBUG:-false}
|
- AI_DEBUG=${AI_DEBUG:-false}
|
||||||
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
@@ -43,8 +46,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
# No host port mapping — dev traffic goes through the Docker nginx container.
|
||||||
- "5173:5173"
|
# Production overlay maps 127.0.0.1:3001 for the host reverse proxy.
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV=${NODE_ENV}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
545
docs/AI_FEATURE_AUDIT.md
Normal file
545
docs/AI_FEATURE_AUDIT.md
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
# AI Feature Audit Report
|
||||||
|
|
||||||
|
**Audit Date:** 2026-03-05
|
||||||
|
**Tenant Under Test:** Pine Creek HOA (`tenant_pine_creek_hoa_q33i`)
|
||||||
|
**AI Model:** Qwen 3.5-397B-A17B via NVIDIA NIM (Temperature: 0.3)
|
||||||
|
**Auditor:** Claude Opus 4.6 (automated)
|
||||||
|
**Data Snapshot Date:** 2026-03-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Three AI-powered features were audited against ground-truth database records: **Operating Fund Health**, **Reserve Fund Health**, and **Investment Recommendations**. Overall, the AI demonstrates strong financial reasoning and produces actionable, fiduciary-appropriate recommendations. However, score consistency across runs is a concern (16-point spread on operating, 20-point spread on reserve), and several specific data interpretation issues were identified.
|
||||||
|
|
||||||
|
| Feature | Latest Score/Grade | Concurrence | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Operating Fund Health | 88 / Good | **72%** | Score ~10-15 pts high; cash runway below its own "Good" threshold |
|
||||||
|
| Reserve Fund Health | 45 / Needs Attention | **85%** | Well-calibrated; minor data misquote on annual contributions |
|
||||||
|
| Investment Recommendations | 6 recommendations | **88%** | Excellent specificity; all market rates verified accurate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Foundation (Ground Truth)
|
||||||
|
|
||||||
|
### Financial Position
|
||||||
|
|
||||||
|
| Metric | Value | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Operating Cash (Checking) | $27,418.81 | GL balance |
|
||||||
|
| Reserve Cash (Savings) | $10,688.45 | GL balance |
|
||||||
|
| Reserve CD #1a (FCB) | $10,000 @ 3.67%, matures 6/19/26 | `investment_accounts` |
|
||||||
|
| Reserve CD #2a (FCB) | $8,000 @ 3.60%, matures 4/14/26 | `investment_accounts` |
|
||||||
|
| Reserve CD #3a (FCB) | $10,000 @ 3.67%, matures 8/18/26 | `investment_accounts` |
|
||||||
|
| Total Reserve Fund | $38,688.45 | Cash + Investments |
|
||||||
|
| Total Assets | $66,107.26 | Operating + Reserve |
|
||||||
|
|
||||||
|
### Budget (FY2026)
|
||||||
|
|
||||||
|
| Category | Annual Total |
|
||||||
|
|---|---|
|
||||||
|
| Operating Income | $184,207.40 |
|
||||||
|
| Operating Expense | $139,979.95 |
|
||||||
|
| **Net Operating Surplus** | **$44,227.45** |
|
||||||
|
| Monthly Expense Run Rate | $11,665.00 |
|
||||||
|
| Reserve Interest Income | $1,449.96 |
|
||||||
|
| Reserve Disbursements | $22,000.00 (Mar $13K, Apr $9K) |
|
||||||
|
|
||||||
|
### Assessment Structure
|
||||||
|
|
||||||
|
- **67 units** at $2,328.14/year regular + $300.00/year special (annual frequency)
|
||||||
|
- Total annual regular assessments: ~$155,985
|
||||||
|
- Total annual special assessments: ~$20,100
|
||||||
|
- Budget timing: assessments front-loaded in Mar-Jun
|
||||||
|
|
||||||
|
### Actuals (YTD through March 4, 2026)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| YTD Income | $88.16 (ARC fees $100 - $50 adj + $38.16 interest) |
|
||||||
|
| YTD Expenses | $1,850.42 (January only) |
|
||||||
|
| Delinquent Invoices | 0 ($0.00) |
|
||||||
|
| Journal Entries Posted | 4 (Jan actuals + Feb adjusting + Feb opening balances) |
|
||||||
|
|
||||||
|
### Capital Projects (from `projects` table, 26 total)
|
||||||
|
|
||||||
|
| Project | Cost | Target | Funded % |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Pond Spillway | $7,000 | Mar 2026 | 0% |
|
||||||
|
| Tuscany Drain Box | $5,500 | May 2026 | 0% |
|
||||||
|
| Front Entrance Power Washing | $1,500 | Mar 2027 | 0% |
|
||||||
|
| Irrigation Pump Replacement | $1,500 | Jun 2027 | 0% |
|
||||||
|
| **Road Sealing - All Roads** | **$80,000** | **Jun 2029** | **0%** |
|
||||||
|
| Asphalt Repair - Creek Stone Dr | $43,000 | TBD | 0% |
|
||||||
|
| Pavilion & Vineyard Structures | $7,000 | Jun 2035 | 0% |
|
||||||
|
| 16 placeholder items | $1.00 each | TBD | 0% |
|
||||||
|
| **Total Planned** | **$152,016** | | **0%** |
|
||||||
|
|
||||||
|
### Reserve Components
|
||||||
|
|
||||||
|
- **0 components tracked** (empty `reserve_components` table)
|
||||||
|
|
||||||
|
### Market Rates (fetched 2026-03-04)
|
||||||
|
|
||||||
|
| Type | Top Rate | Bank | Term |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CD | 4.10% | E*TRADE / Synchrony | 12-14 mo |
|
||||||
|
| High-Yield Savings | 4.09% | Openbank | Liquid |
|
||||||
|
| Money Market | 4.03% | Vio Bank | Liquid |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Operating Fund Health Score
|
||||||
|
|
||||||
|
**Latest Score:** 88 (Good) — Generated 2026-03-04T19:24:36Z
|
||||||
|
**Score History:** 48 → 72 → 78 → 72 → 78 → **88** (6 runs, March 2-4)
|
||||||
|
**Overall Concurrence: 72%**
|
||||||
|
|
||||||
|
### Factor-by-Factor Analysis
|
||||||
|
|
||||||
|
#### Factor 1: "Projected Cash Flow" — Impact: Positive
|
||||||
|
> "12-month forecast shows consistent positive liquidity, with cash balances never dipping below the starting $27,419 and peaking at $142,788 in June."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Budget surplus ($184K income vs $140K expense) | **Verified** ✅ |
|
||||||
|
| Assessments front-loaded Mar-Jun | **Verified** ✅ (budget shows $48K Mar, $64K Apr, $32K May, $16K Jun) |
|
||||||
|
| Peak of ~$142K in June | **Plausible** ✅ ($27K + cumulative income through June) |
|
||||||
|
| Cash never below starting $27K | **Plausible** ✅ (expenses < income by month) |
|
||||||
|
|
||||||
|
**Concurrence: 95%** — Forecast logic is sound. The only risk is the assumption that assessments are collected on the exact budget schedule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 2: "Delinquency Rate" — Impact: Positive
|
||||||
|
> "$0.00 in overdue invoices and a 0.0% delinquency rate."
|
||||||
|
|
||||||
|
**Concurrence: 100%** ✅ — Database confirms zero delinquent invoices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 3: "Budget Performance (Timing)" — Impact: Neutral
|
||||||
|
> "YTD income is 99.8% below budget ($55k variance) primarily due to the timing of the large Special Assessment ($20,700) and regular assessments appearing in future projected months."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| YTD income $88.16 | **Verified** ✅ |
|
||||||
|
| Budget includes March ($55K) in YTD calc | **Accurate** — AI uses month 3 of 12, includes full March budget |
|
||||||
|
| Timing explanation | **Reasonable** — we're only 4 days into March |
|
||||||
|
| Rating as "neutral" vs "negative" | **Appropriate** ✅ — correctly avoids penalizing for calendar timing |
|
||||||
|
|
||||||
|
**Concurrence: 80%** — The variance is accurately computed but presenting a $55K "variance" when we're 4 days into March could alarm a board member. The YTD window through month 3 includes all of March's budget despite only 4 days having elapsed. Consider computing YTD budget pro-rata or through the prior complete month.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add a note to the prompt about pro-rating the current month's budget, or instruct the AI to note "X days into the current month" when the variance is driven by incomplete-month timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 4: "Cash Reserves" — Impact: Positive
|
||||||
|
> "Current operating cash of $27,419 provides 2.4 months of runway based on the annual expense run rate."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| $27,419 / ($139,980 / 12) = 2.35 months | **Math verified** ✅ |
|
||||||
|
| Rated as "positive" | **Questionable** ⚠️ |
|
||||||
|
|
||||||
|
**Concurrence: 60%** — The math is correct, but rating 2.4 months as "positive" contradicts the scoring guidelines which state 2-3 months = "Fair" (60-74) and 3-6 months = "Good" (75-89). This factor should be "neutral" at best, and the overall score should reflect that the HOA is *below* the "Good" threshold for cash reserves.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add explicit guidance in the prompt: "If cash runway is below 3 months, this factor MUST be neutral or negative, regardless of projected future inflows."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 5: "Expense Management" — Impact: Positive
|
||||||
|
> "YTD expenses are $36,313 under budget (4.8% of annual budget spent vs 25% of year elapsed)."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| YTD expenses $1,850.42 | **Verified** ✅ |
|
||||||
|
| Budget YTD (3 months): ~$38,164 | **Correct** ✅ |
|
||||||
|
| $1,850 / $38,164 = 4.85% | **Math verified** ✅ |
|
||||||
|
| "25% of year elapsed" | **Correct** (month 3 of 12) |
|
||||||
|
| Phrasing "of annual budget" | **Misleading** ⚠️ — it's actually 4.8% of YTD budget, not annual |
|
||||||
|
|
||||||
|
**Concurrence: 70%** — The percentage is correctly calculated against YTD budget, but the phrasing "of annual budget" is incorrect. Also, the low spend is not necessarily positive — only January actuals exist; February hasn't been posted yet, which the AI partially acknowledges with "or delayed billing cycles."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommendation Assessment
|
||||||
|
|
||||||
|
| # | Recommendation | Priority | Concurrence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | "Verify the posting schedule for the $20,700 Special Assessment" | Low | **90%** ✅ Valid; assessments are annual, collection timing matters |
|
||||||
|
| 2 | "Investigate the low YTD expense recognition ($1,850 vs $38,164)" | Medium | **95%** ✅ Excellent catch; Feb expenses not posted yet |
|
||||||
|
| 3 | "Consider moving excess cash over $100K in Q2 to interest-bearing account" | Low | **85%** ✅ Sound advice; aligns with HY Savings at 4.09% |
|
||||||
|
|
||||||
|
**Recommendation Concurrence: 90%** — All three recommendations are actionable and data-backed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Assessment
|
||||||
|
|
||||||
|
**Is 88 (Good) the right score?**
|
||||||
|
|
||||||
|
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cash reserves | 3-6 months for "Good" | 2.4 months | ❌ Below threshold |
|
||||||
|
| Income vs expenses | "Roughly matching" for Good | $184K vs $140K (surplus) | ✅ Exceeds |
|
||||||
|
| Delinquency | "Manageable" for Good | 0% | ✅ Excellent |
|
||||||
|
| Budget performance | No major overruns for Good | Under budget (timing) | ✅ Positive |
|
||||||
|
| Projected cash flow | Not explicitly in guidelines | Strong positive trajectory | ✅ Positive |
|
||||||
|
|
||||||
|
The cash runway of 2.4 months is below the stated "Good" (75-89) threshold of 3-6 months and technically falls in the "Fair" (60-74) range of 2-3 months. Earlier AI runs scored this 72-78, which better aligns with the guidelines. The 88 appears to overweight the projected future cash flow (which is speculative) vs the current actual position.
|
||||||
|
|
||||||
|
**Suggested correct score: 74-80** (high end of Fair to low end of Good)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Consistency Concern
|
||||||
|
|
||||||
|
| Run Date | Score | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| Mar 2 15:07 | 48 | Needs Attention |
|
||||||
|
| Mar 2 15:12 | 78 | Good |
|
||||||
|
| Mar 2 15:36 | 72 | Fair |
|
||||||
|
| Mar 2 17:09 | 78 | Good |
|
||||||
|
| Mar 3 02:03 | 72 | Fair |
|
||||||
|
| Mar 4 19:24 | 88 | Good |
|
||||||
|
|
||||||
|
A **40-point spread** (48-88) across 6 runs with essentially the same data is concerning. Even excluding the outlier first run (which noted a data config issue with "1 units"), the remaining 5 runs span 72-88 (16 points). At temperature 0.3, this suggests the model is not deterministic enough for financial scoring.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Consider lowering temperature to 0.1 for health score calculations to improve consistency. Alternatively, implement a moving average of the last 3 scores to smooth volatility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reserve Fund Health Score
|
||||||
|
|
||||||
|
**Latest Score:** 45 (Needs Attention) — Generated 2026-03-04T19:24:50Z
|
||||||
|
**Score History:** 25 → 48 → 42 → 25 → 45 → 35 → **45** (7 runs, March 2-4)
|
||||||
|
**Overall Concurrence: 85%**
|
||||||
|
|
||||||
|
### Factor-by-Factor Analysis
|
||||||
|
|
||||||
|
#### Factor 1: "Funded Ratio" — Impact: Negative
|
||||||
|
> "Calculated at 0% because no reserve components have been inventoried or assigned replacement costs, making it impossible to measure true funding health against the $152,016 in planned projects."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| 0 reserve components in DB | **Verified** ✅ |
|
||||||
|
| $152,016 in planned projects | **Verified** ✅ (sum of all `projects` rows) |
|
||||||
|
| 0% funded ratio | **Technically accurate** ✅ (no denominator from components) |
|
||||||
|
| Distinction between components and projects | **Well articulated** ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 95%** — The AI correctly identifies that the 0% is an artifact of missing reserve study data, not a literal lack of funds. It appropriately flags this as a governance failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 2: "Projected Cash Flow" — Impact: Positive
|
||||||
|
> "Strong immediate liquidity; cash balance is projected to rise from $10,688 to over $49,000 by May 2026 due to special assessment income covering the $12,500 in urgent 2026 project costs."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Starting reserve cash $10,688 | **Verified** ✅ |
|
||||||
|
| 2026 project costs: $7K (Mar) + $5.5K (May) = $12,500 | **Verified** ✅ |
|
||||||
|
| Special assessment: $300 × 67 = $20,100/year | **Verified** ✅ |
|
||||||
|
| CD maturities: $8K (Apr), $10K (Jun), $10K (Aug) | **Verified** ✅ |
|
||||||
|
| Projected rise to $49K by May | **Plausible** ✅ (income + maturities - project costs) |
|
||||||
|
|
||||||
|
**Concurrence: 85%** — Math is directionally correct. However, the assessment is annual frequency so the full $20,100 may arrive in a single payment, not spread monthly. The timing assumption is critical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 3: "Component Tracking" — Impact: Negative
|
||||||
|
> "Critical failure in governance: 'No reserve components tracked' means the association is flying blind on the condition and remaining useful life of major assets like roads and irrigation."
|
||||||
|
|
||||||
|
**Concurrence: 100%** ✅ — Database confirms 0 rows in `reserve_components`. This is objectively a critical gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 4: "Annual Contributions" — Impact: Negative
|
||||||
|
> "Recurring annual reserve income is only $300 (plus minimal interest), which is grossly insufficient to fund the $80,000 road sealing project due in 2029."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Reserve budget income: $1,449.96/yr (interest only) | **Verified** ✅ |
|
||||||
|
| Special assessment: $300/unit × 67 = $20,100/yr | **Verified** ✅ |
|
||||||
|
| "$300" cited as annual reserve income | **Incorrect** ⚠️ |
|
||||||
|
| Road Sealing $80K in June 2029 | **Verified** ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 65%** — The concern about insufficient contributions is valid, but the "$300" figure appears to confuse the per-unit special assessment amount ($300/unit) with the total annual reserve income. Actual annual reserve income = $1,450 (interest) + $20,100 (special assessments) = **$21,550/yr**. Even at $21,550/yr, the 3 years until Road Sealing would accumulate ~$64,650, still short of $80K. So the directional concern is correct, but the magnitude is significantly misstated.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** The prompt should explicitly label the special assessment income total (not per-unit) in the data context. Currently the data says "$300.00/unit × 67 units (annual)" — the AI should compute $20,100 but sometimes fixates on the $300 per-unit figure. Consider pre-computing and passing the total.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommendation Assessment
|
||||||
|
|
||||||
|
| # | Recommendation | Priority | Concurrence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | "Commission a professional Reserve Study to inventory assets and establish funded ratio" | High | **100%** ✅ Critical and universally correct |
|
||||||
|
| 2 | "Develop a long-term funding plan for the $80,000 Road Sealing project (2029)" | High | **90%** ✅ Verified project exists; $80K with 0% funded |
|
||||||
|
| 3 | "Formalize collection of special assessments into the reserve fund vs operating" | Medium | **95%** ✅ Budget shows special assessments in operating income section |
|
||||||
|
|
||||||
|
**Recommendation Concurrence: 95%** — All recommendations are actionable, appropriately prioritized, and backed by database evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Assessment
|
||||||
|
|
||||||
|
**Is 45 (Needs Attention) the right score?**
|
||||||
|
|
||||||
|
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Percent funded | 20-30% for "Needs Attention" | 0% (no components) | ⬇️ Worse than threshold |
|
||||||
|
| Contributions | "Inadequate" for Needs Attention | $21,550/yr for $152K in projects | ⚠️ Borderline |
|
||||||
|
| Component tracking | "Multiple urgent unfunded" | 0 tracked, 2 due in 2026 | ❌ Critical gap |
|
||||||
|
| Investments | Not scored negatively | 3 CDs earning 3.6-3.67% | ✅ Positive |
|
||||||
|
| Capital readiness | | $12.5K due soon, only $10.7K cash | ⚠️ Tight |
|
||||||
|
|
||||||
|
A score of 45 is reasonable. The 0% funded ratio technically suggests "At Risk" (20-39), but the presence of real assets ($38.7K), active investments, and manageable near-term liquidity justifies bumping it into the "Needs Attention" band. The AI's balancing of the artificial 0% metric against actual fund health shows good judgment.
|
||||||
|
|
||||||
|
**Suggested correct score: 40-50** — the AI's 45 is well-calibrated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Consistency Concern
|
||||||
|
|
||||||
|
| Run Date | Score | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| Mar 2 15:06 | 25 | At Risk |
|
||||||
|
| Mar 2 15:13 | 25 | At Risk |
|
||||||
|
| Mar 2 15:37 | 48 | Needs Attention |
|
||||||
|
| Mar 2 17:10 | 42 | Needs Attention |
|
||||||
|
| Mar 3 02:04 | 45 | Needs Attention |
|
||||||
|
| Mar 4 18:49 | 35 | At Risk |
|
||||||
|
| Mar 4 19:24 | 45 | Needs Attention |
|
||||||
|
|
||||||
|
A **23-point spread** (25-48) across 7 runs. The scores oscillate between "At Risk" and "Needs Attention" — the model cannot consistently decide which band this falls into. The most recent 3 runs (35, 45, 45) are more stable.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add boundary guidance to the prompt: "When the score falls within ±5 points of a threshold (40, 60, 75, 90), explicitly justify which side of the boundary the HOA falls on."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AI Investment Recommendations
|
||||||
|
|
||||||
|
**Latest Run:** 2026-03-04T19:28:22Z (3 runs saved)
|
||||||
|
**Overall Concurrence: 88%**
|
||||||
|
|
||||||
|
### Overall Assessment
|
||||||
|
> "The HOA has a healthy long-term cash flow outlook with significant surpluses projected by mid-2026, but faces an immediate liquidity pinch in the Reserve Fund for March/April capital projects. The current investment strategy relies on older, lower-yielding CDs (3.60-3.67%) that are maturing soon."
|
||||||
|
|
||||||
|
**Concurrence: 92%** ✅ — Every claim verified:
|
||||||
|
- CDs are at 3.60-3.67% vs market 4.10% (verified)
|
||||||
|
- March project ($7K) vs reserve cash ($10.7K) is tight (verified)
|
||||||
|
- Long-term surplus projected from assessment income (verified from budget)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommendation-by-Recommendation Analysis
|
||||||
|
|
||||||
|
#### Rec 1: "Critical Reserve Shortfall for March Project" — HIGH / Liquidity Warning
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| Reserve cash = $10,688 | $10,688.45 | ✅ Exact |
|
||||||
|
| $7,000 Pond Spillway project due March | Projects table: $7,000, Mar 2026 | ✅ Exact |
|
||||||
|
| Shortfall risk | $10,688 - $7,000 = $3,688 remaining — tight but feasible | ✅ |
|
||||||
|
| Suggested action: expedite special assessment or transfer from operating | Sound advice | ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 90%** — The liquidity concern is real. After paying the $7K project, only $3.7K would remain in reserve cash before the $5.5K May project. The AI correctly flags the timing risk even though the fund is technically solvent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 2: "Reinvest Maturing CD #2a at Higher Rate" — HIGH / Maturity Action
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| CD #2a = $8,000 | $8,000.00 | ✅ Exact |
|
||||||
|
| Current rate = 3.60% | 3.60% | ✅ Exact |
|
||||||
|
| Maturity = April 14, 2026 | 2026-04-14 | ✅ Exact |
|
||||||
|
| Market rate = 4.10% (E*TRADE) | CD rates: E*TRADE 4.10%, 1 year, $0 min | ✅ Exact |
|
||||||
|
| Additional yield: ~$40/year per $8K | $8K × 0.50% = $40 | ✅ Math correct |
|
||||||
|
|
||||||
|
**Concurrence: 95%** ✅ — Textbook-correct recommendation. Every data point verified. The 50 bps improvement is risk-free income.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 3: "Establish 12-Month CD Ladder for Reserves" — MEDIUM / CD Ladder
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| ~$38K total reserve portfolio | $38,688.45 | ✅ Exact |
|
||||||
|
| Suggest 4-rung ladder (3/6/9/12 mo) | Standard strategy | ✅ |
|
||||||
|
| Rates up to 4.10% | Market data confirmed | ✅ |
|
||||||
|
| $9K matures every quarter | $38K / 4 = $9.5K per rung | ✅ Approximate |
|
||||||
|
|
||||||
|
**Concurrence: 75%** — Strategy is sound in principle, but the recommendation overlooks two constraints:
|
||||||
|
1. **Immediate project costs ($12.5K in 2026)** must be reserved first, leaving ~$26K for laddering
|
||||||
|
2. **Investing the entire $38K** is aggressive — some cash buffer should remain liquid
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add a constraint to the prompt: "When recommending CD ladders, always subtract upcoming project costs (next 12 months) and a minimum emergency reserve (1 month of budgeted reserve expenses) before calculating the investable amount."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 4: "Deploy Excess Operating Cash to High-Yield Savings" — MEDIUM / New Investment
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| Operating cash = $27,418 | $27,418.81 | ✅ Exact |
|
||||||
|
| 3-month buffer = ~$35,000 | $11,665 × 3 = $34,995 | ✅ Math correct |
|
||||||
|
| Current cash below buffer | $27.4K < $35K | ✅ Correctly identified |
|
||||||
|
| Openbank 4.09% APY | Market data: Openbank 4.09%, $0.01 min | ✅ Exact |
|
||||||
|
| Trigger: "As soon as balance exceeds $35K" | Sound deferred recommendation | ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 90%** ✅ — The AI correctly identifies the current shortfall and provides a forward-looking trigger. Well-structured advice that respects the liquidity constraint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 5: "Optimize Reserve Cash Yield Post-Project" — LOW / Reallocation
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| Vio Bank Money Market at 4.03% | Market data: Vio Bank 4.03%, $0 min | ✅ Exact |
|
||||||
|
| Post-project reserve cash deployment | Appropriate timing | ✅ |
|
||||||
|
| T+1 liquidity for emergencies | Correct MM account characteristic | ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 85%** ✅ — Reasonable low-priority optimization. Correctly uses market data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 6: "Formalize Special Assessment Collection for Reserves" — LOW / General
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| $300/unit special assessment | Assessment groups: $300.00 special | ✅ Exact |
|
||||||
|
| Risk of commingling with operating | Budget shows special assessments in operating income | ✅ Identified |
|
||||||
|
|
||||||
|
**Concurrence: 90%** ✅ — Important governance recommendation. The budget structure does show special assessments as operating income, which could lead to improper fund commingling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk Notes Assessment
|
||||||
|
|
||||||
|
| Risk Note | Verified | Concurrence |
|
||||||
|
|---|---|---|
|
||||||
|
| "Reserve cash ($10.6K) barely sufficient for $7K + $5.5K projects" | ✅ $10,688 vs $12,500 in projects | **95%** |
|
||||||
|
| "Concentration risk: CDs maturing in 4-month window (Apr-Aug)" | ✅ All 3 CDs mature Apr-Aug 2026 | **100%** |
|
||||||
|
| "Operating cash ballooning to $140K+ without investment plan" | ✅ Budget shows large Q2 surplus | **85%** |
|
||||||
|
| "Road Sealing $80K in 2029 needs dedicated savings plan" | ✅ Project exists, 0% funded | **95%** |
|
||||||
|
|
||||||
|
**Risk Notes Concurrence: 94%** — All risk items are data-backed and appropriately flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cross-Run Consistency (Investment Recommendations)
|
||||||
|
|
||||||
|
Three runs were compared. Key observations:
|
||||||
|
- **Core recommendations are highly consistent** across runs: CD reinvestment, HY savings for operating, CD ladder for reserves
|
||||||
|
- **Dollar amounts match exactly** across all runs (same data inputs)
|
||||||
|
- **Bank name recommendations vary slightly** (E*TRADE vs "Top CD Rate") — cosmetic, not substantive
|
||||||
|
- **Priority levels are stable** (HIGH for liquidity warnings, MEDIUM for optimization)
|
||||||
|
|
||||||
|
**Consistency Grade: A-** — Investment recommendations show much better consistency than health scores, likely because the structured data (specific CDs, specific rates) constrains the output more than the subjective health scoring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Cutting Issues
|
||||||
|
|
||||||
|
### Issue 1: Score Volatility (MEDIUM Priority)
|
||||||
|
|
||||||
|
Health scores vary significantly across runs despite identical input data:
|
||||||
|
- Operating: 40-point spread (48-88)
|
||||||
|
- Reserve: 23-point spread (25-48)
|
||||||
|
|
||||||
|
**Root Cause:** Temperature 0.3 allows too much variance for numerical scoring. The model interprets guidelines subjectively.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
1. Reduce temperature to **0.1** for health score calculations
|
||||||
|
2. Implement a **3-run moving average** to smooth individual run variance
|
||||||
|
3. Add explicit **boundary justification** requirements to prompts
|
||||||
|
|
||||||
|
### Issue 2: YTD Budget Calculation Includes Incomplete Month (LOW Priority)
|
||||||
|
|
||||||
|
The operating health score computes YTD budget through the current month (March), but actual data may only cover a few days. This creates alarming income variances (e.g., "$55K variance") that are pure timing artifacts.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Compute YTD budget through the **prior completed month** (February)
|
||||||
|
- OR pro-rate the current month's budget by days elapsed
|
||||||
|
- Add a note to the prompt: "If the variance is driven by the current incomplete month, flag it as 'timing' and weight it minimally."
|
||||||
|
|
||||||
|
### Issue 3: Per-Unit vs Total Confusion on Special Assessments (LOW Priority)
|
||||||
|
|
||||||
|
The AI sometimes quotes "$300" as the annual reserve income instead of $300 × 67 = $20,100. The data passed says "$300.00/unit × 67 units (annual)" but the model occasionally fixates on the per-unit figure.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Pre-compute and include the total in the data: "Total Annual Special Assessment Income: $20,100.00"
|
||||||
|
- Keep the per-unit breakdown for context but lead with the total
|
||||||
|
|
||||||
|
### Issue 4: Cash Runway Classification Inconsistency (MEDIUM Priority)
|
||||||
|
|
||||||
|
The operating health score rates 2.4 months of cash runway as "positive" despite the scoring guidelines defining 2-3 months as "Fair" territory. This inflates the overall score.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Add explicit prompt guidance: "Cash runway categorization: <2 months = negative, 2-3 months = neutral, 3-6 months = positive, 6+ months = strongly positive. Do NOT rate below-threshold runway as positive based on projected future inflows."
|
||||||
|
|
||||||
|
### Issue 5: Dual Project Tables (INFORMATIONAL)
|
||||||
|
|
||||||
|
The schema contains both `capital_projects` (empty) and `projects` (26 rows). The health score service correctly queries `projects`, but auditors initially checked `capital_projects` and found no data. This dual-table pattern could confuse future developers.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Consolidate into a single table, OR
|
||||||
|
- Add a comment/documentation clarifying the canonical source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concurrence Summary by Recommendation
|
||||||
|
|
||||||
|
### Operating Fund Health — Recommendations
|
||||||
|
| Recommendation | Concurrence |
|
||||||
|
|---|---|
|
||||||
|
| Verify posting schedule for $20,700 Special Assessment | 90% |
|
||||||
|
| Investigate low YTD expense recognition | 95% |
|
||||||
|
| Move excess cash to interest-bearing account | 85% |
|
||||||
|
| **Average** | **90%** |
|
||||||
|
|
||||||
|
### Reserve Fund Health — Recommendations
|
||||||
|
| Recommendation | Concurrence |
|
||||||
|
|---|---|
|
||||||
|
| Commission professional Reserve Study | 100% |
|
||||||
|
| Develop funding plan for $80K Road Sealing | 90% |
|
||||||
|
| Formalize special assessment collection for reserves | 95% |
|
||||||
|
| **Average** | **95%** |
|
||||||
|
|
||||||
|
### Investment Planning — Recommendations
|
||||||
|
| Recommendation | Concurrence |
|
||||||
|
|---|---|
|
||||||
|
| Critical Reserve Shortfall for March Project | 90% |
|
||||||
|
| Reinvest Maturing CD #2a at Higher Rate | 95% |
|
||||||
|
| Establish 12-Month CD Ladder | 75% |
|
||||||
|
| Deploy Operating Cash to HY Savings | 90% |
|
||||||
|
| Optimize Reserve Cash Post-Project | 85% |
|
||||||
|
| Formalize Special Assessment Collection | 90% |
|
||||||
|
| **Average** | **88%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Grades
|
||||||
|
|
||||||
|
| Feature | Score Accuracy | Recommendation Quality | Data Fidelity | Consistency | **Overall** |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Operating Fund Health | C+ (score ~15 pts high) | A (90%) | B+ (minor math phrasing) | C (16-pt spread) | **72% — B-** |
|
||||||
|
| Reserve Fund Health | A- (well-calibrated) | A (95%) | B (per-unit confusion) | B- (23-pt spread) | **85% — B+** |
|
||||||
|
| Investment Recommendations | N/A (no single score) | A (88%) | A (exact data matches) | A- (stable across runs) | **88% — A-** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Action Items for Tuning
|
||||||
|
|
||||||
|
1. **[HIGH]** Reduce AI temperature from 0.3 → 0.1 for health score calculations to reduce score volatility
|
||||||
|
2. **[MEDIUM]** Add explicit cash-runway-to-impact mapping in operating prompt to prevent misclassification
|
||||||
|
3. **[MEDIUM]** Pre-compute total special assessment income in data context (not just per-unit)
|
||||||
|
4. **[LOW]** Adjust YTD budget calculation to use prior completed month or pro-rate current month
|
||||||
|
5. **[LOW]** Add boundary justification requirement to scoring prompts
|
||||||
|
6. **[LOW]** Consider implementing 3-run moving average for displayed health scores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated by Claude Opus 4.6 — Automated AI Feature Audit*
|
||||||
22
frontend/Dockerfile
Normal file
22
frontend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# ---- Production Dockerfile for React frontend ----
|
||||||
|
# Multi-stage build: compile to static assets, serve with nginx
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy the built static files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy a small nginx config for SPA routing
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal nginx config for serving the React SPA inside the frontend container.
|
||||||
|
# The outer nginx reverse proxy forwards non-API requests here.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 3001;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Serve static assets with long cache (Vite hashes filenames)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — any non-file route returns index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3324
frontend/package-lock.json
generated
Normal file
3324
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "0.2.0",
|
"version": "2026.3.7-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,31 +11,32 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/hooks": "^7.15.3",
|
|
||||||
"@mantine/form": "^7.15.3",
|
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
"@mantine/notifications": "^7.15.3",
|
"@mantine/form": "^7.15.3",
|
||||||
|
"@mantine/hooks": "^7.15.3",
|
||||||
"@mantine/modals": "^7.15.3",
|
"@mantine/modals": "^7.15.3",
|
||||||
|
"@mantine/notifications": "^7.15.3",
|
||||||
"@tabler/icons-react": "^3.28.1",
|
"@tabler/icons-react": "^3.28.1",
|
||||||
|
"@tanstack/react-query": "^5.64.2",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"d3-sankey": "^0.12.3",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-joyride": "^2.9.3",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"zustand": "^4.5.5"
|
||||||
"zustand": "^4.5.5",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"@tanstack/react-query": "^5.64.2",
|
|
||||||
"dayjs": "^1.11.13"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/d3-sankey": "^0.12.4",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/d3-sankey": "^0.12.4",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vite": "^5.4.14",
|
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1"
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^5.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
|
|||||||
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
|
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
@@ -135,6 +136,7 @@ export function App() {
|
|||||||
<Route path="reports/aging" element={<AgingReportPage />} />
|
<Route path="reports/aging" element={<AgingReportPage />} />
|
||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
import { useState, useEffect } from 'react';
|
||||||
|
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,
|
||||||
@@ -8,18 +9,58 @@ import {
|
|||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
|
IconSun,
|
||||||
|
IconMoon,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate } 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 { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
import logoSrc from '../../assets/logo.svg';
|
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 isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
|
// ── Onboarding State ──
|
||||||
|
const [showTour, setShowTour] = useState(false);
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run for non-impersonating users with an org selected, on dashboard
|
||||||
|
if (isImpersonating || !currentOrg || !user) return;
|
||||||
|
if (!location.pathname.startsWith('/dashboard')) return;
|
||||||
|
// Read-only users (viewers) skip onboarding entirely
|
||||||
|
if (currentOrg.role === 'viewer') return;
|
||||||
|
|
||||||
|
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
|
||||||
|
// Delay to ensure DOM elements are rendered for tour targeting
|
||||||
|
const timer = setTimeout(() => setShowTour(true), 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (currentOrg.settings?.onboardingComplete !== true) {
|
||||||
|
setShowWizard(true);
|
||||||
|
}
|
||||||
|
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
|
||||||
|
|
||||||
|
const handleTourComplete = () => {
|
||||||
|
setShowTour(false);
|
||||||
|
// After tour, check if onboarding wizard should run
|
||||||
|
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
|
||||||
|
// Small delay before showing wizard
|
||||||
|
setTimeout(() => setShowWizard(true), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWizardComplete = () => {
|
||||||
|
setShowWizard(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -71,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>
|
||||||
@@ -145,6 +196,10 @@ export function AppLayout() {
|
|||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{/* ── Onboarding Components ── */}
|
||||||
|
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||||
|
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,23 +30,23 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Financials',
|
label: 'Financials',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
|
||||||
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
||||||
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
||||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
],
|
],
|
||||||
@@ -56,7 +56,7 @@ const navSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
|
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -66,6 +66,7 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
icon: IconChartSankey,
|
icon: IconChartSankey,
|
||||||
|
tourId: 'nav-reports',
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||||
@@ -74,6 +75,7 @@ const navSections = [
|
|||||||
{ label: 'Aging Report', path: '/reports/aging' },
|
{ label: 'Aging Report', path: '/reports/aging' },
|
||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
{ label: 'Year-End', path: '/reports/year-end' },
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
|
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -127,7 +129,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm">
|
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||||
{navSections.map((section, sIdx) => (
|
{navSections.map((section, sIdx) => (
|
||||||
<div key={sIdx}>
|
<div key={sIdx}>
|
||||||
{section.label && (
|
{section.label && (
|
||||||
@@ -147,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
defaultOpened={item.children.some((c: any) =>
|
defaultOpened={item.children.some((c: any) =>
|
||||||
location.pathname.startsWith(c.path),
|
location.pathname.startsWith(c.path),
|
||||||
)}
|
)}
|
||||||
|
data-tour={item.tourId || undefined}
|
||||||
>
|
>
|
||||||
{item.children.map((child: any) => (
|
{item.children.map((child: any) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -164,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
leftSection={<item.icon size={18} />}
|
leftSection={<item.icon size={18} />}
|
||||||
active={location.pathname === item.path}
|
active={location.pathname === item.path}
|
||||||
onClick={() => go(item.path!)}
|
onClick={() => go(item.path!)}
|
||||||
|
data-tour={item.tourId || undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
|
||||||
|
import { TOUR_STEPS } from '../../config/tourSteps';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AppTourProps {
|
||||||
|
run: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppTour({ run, onComplete }: AppTourProps) {
|
||||||
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
|
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
|
||||||
|
|
||||||
|
const handleCallback = useCallback(
|
||||||
|
async (data: CallBackProps) => {
|
||||||
|
const { status, action, type } = data;
|
||||||
|
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
|
||||||
|
|
||||||
|
if (finishedStatuses.includes(status)) {
|
||||||
|
// Mark intro as seen on backend (fire-and-forget)
|
||||||
|
api.patch('/auth/intro-seen').catch(() => {});
|
||||||
|
setUserIntroSeen();
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle step navigation
|
||||||
|
if (type === EVENTS.STEP_AFTER) {
|
||||||
|
setStepIndex((prev) =>
|
||||||
|
action === ACTIONS.PREV ? prev - 1 : prev + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onComplete, setUserIntroSeen],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!run) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Joyride
|
||||||
|
steps={TOUR_STEPS}
|
||||||
|
run={run}
|
||||||
|
stepIndex={stepIndex}
|
||||||
|
continuous
|
||||||
|
showProgress
|
||||||
|
showSkipButton
|
||||||
|
scrollToFirstStep
|
||||||
|
disableOverlayClose
|
||||||
|
callback={handleCallback}
|
||||||
|
styles={{
|
||||||
|
options: {
|
||||||
|
primaryColor: '#228be6',
|
||||||
|
zIndex: 10000,
|
||||||
|
arrowColor: '#fff',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
textColor: '#333',
|
||||||
|
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
tooltipTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
buttonNext: {
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: '8px 16px',
|
||||||
|
},
|
||||||
|
buttonBack: {
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
buttonSkip: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
locale={{
|
||||||
|
back: 'Previous',
|
||||||
|
close: 'Close',
|
||||||
|
last: 'Finish Tour',
|
||||||
|
next: 'Next',
|
||||||
|
skip: 'Skip Tour',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
||||||
|
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
|
||||||
|
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBuildingBank, IconUsers, IconFileSpreadsheet,
|
||||||
|
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
|
interface OnboardingWizardProps {
|
||||||
|
opened: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnitRow {
|
||||||
|
unitNumber: string;
|
||||||
|
ownerName: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV Parsing (reused from BudgetsPage pattern) ──
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.split('\n').filter((l) => l.trim());
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||||
|
return lines.slice(1).map((line) => {
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (const char of line) {
|
||||||
|
if (char === '"') { inQuotes = !inQuotes; }
|
||||||
|
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
||||||
|
else { current += char; }
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, i) => { row[h] = values[i] || ''; });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
|
||||||
|
|
||||||
|
// ── Step 1: Account State ──
|
||||||
|
const [accountCreated, setAccountCreated] = useState(false);
|
||||||
|
const [accountName, setAccountName] = useState('Operating Checking');
|
||||||
|
const [accountNumber, setAccountNumber] = useState('1000');
|
||||||
|
const [accountDescription, setAccountDescription] = useState('');
|
||||||
|
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||||
|
|
||||||
|
// ── Step 2: Assessment Group State ──
|
||||||
|
const [groupCreated, setGroupCreated] = useState(false);
|
||||||
|
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||||
|
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||||
|
const [frequency, setFrequency] = useState('monthly');
|
||||||
|
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||||
|
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||||
|
|
||||||
|
// ── Step 3: Budget State ──
|
||||||
|
const [budgetFile, setBudgetFile] = useState<File | null>(null);
|
||||||
|
const [budgetUploaded, setBudgetUploaded] = useState(false);
|
||||||
|
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// ── Step 1: Create Account ──
|
||||||
|
const handleCreateAccount = async () => {
|
||||||
|
if (!accountName.trim()) {
|
||||||
|
setError('Account name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!accountNumber.trim()) {
|
||||||
|
setError('Account number is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
|
||||||
|
if (isNaN(balance)) {
|
||||||
|
setError('Initial balance must be a valid number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post('/accounts', {
|
||||||
|
accountNumber: accountNumber.trim(),
|
||||||
|
name: accountName.trim(),
|
||||||
|
description: accountDescription.trim(),
|
||||||
|
accountType: 'asset',
|
||||||
|
fundType: 'operating',
|
||||||
|
initialBalance: balance,
|
||||||
|
});
|
||||||
|
setAccountCreated(true);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Account Created',
|
||||||
|
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to create account';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step 2: Create Assessment Group ──
|
||||||
|
const handleCreateGroup = async () => {
|
||||||
|
if (!groupName.trim()) {
|
||||||
|
setError('Group name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
|
||||||
|
if (isNaN(assessment) || assessment <= 0) {
|
||||||
|
setError('Assessment amount must be greater than zero');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { data: group } = await api.post('/assessment-groups', {
|
||||||
|
name: groupName.trim(),
|
||||||
|
regularAssessment: assessment,
|
||||||
|
frequency,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
setGroupCreated(true);
|
||||||
|
|
||||||
|
// Create units if any were added
|
||||||
|
if (units.length > 0) {
|
||||||
|
let created = 0;
|
||||||
|
for (const unit of units) {
|
||||||
|
if (!unit.unitNumber.trim()) continue;
|
||||||
|
try {
|
||||||
|
await api.post('/units', {
|
||||||
|
unitNumber: unit.unitNumber.trim(),
|
||||||
|
ownerName: unit.ownerName.trim() || null,
|
||||||
|
ownerEmail: unit.ownerEmail.trim() || null,
|
||||||
|
assessmentGroupId: group.id,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} catch {
|
||||||
|
// Continue even if a unit fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUnitsCreated(true);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Assessment Group Created',
|
||||||
|
message: `${groupName} created with ${created} unit(s)`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Assessment Group Created',
|
||||||
|
message: `${groupName} created successfully`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to create assessment group';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step 3: Budget Import ──
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/budgets/${currentYear}/template`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `budget_template_${currentYear}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to download template',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadBudget = async () => {
|
||||||
|
if (!budgetFile) {
|
||||||
|
setError('Please select a CSV file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const text = await budgetFile.text();
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
setError('CSV file appears to be empty or invalid');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
|
||||||
|
setBudgetUploaded(true);
|
||||||
|
setBudgetImportResult(data);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Budget Imported',
|
||||||
|
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to import budget';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Finish Wizard ──
|
||||||
|
const handleFinish = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.patch('/organizations/settings', { onboardingComplete: true });
|
||||||
|
setOrgSettings({ onboardingComplete: true });
|
||||||
|
onComplete();
|
||||||
|
} catch {
|
||||||
|
// Even if API fails, close the wizard — onboarding data is already created
|
||||||
|
onComplete();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Unit Rows ──
|
||||||
|
const addUnit = () => {
|
||||||
|
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
|
||||||
|
const updated = [...units];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setUnits(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUnit = (index: number) => {
|
||||||
|
setUnits(units.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Navigation ──
|
||||||
|
const canGoNext = () => {
|
||||||
|
if (active === 0) return accountCreated;
|
||||||
|
if (active === 1) return groupCreated;
|
||||||
|
if (active === 2) return true; // Budget is optional
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
setError(null);
|
||||||
|
if (active < 3) setActive(active + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => {}} // Prevent closing without completing
|
||||||
|
withCloseButton={false}
|
||||||
|
size="xl"
|
||||||
|
centered
|
||||||
|
overlayProps={{ opacity: 0.6, blur: 3 }}
|
||||||
|
styles={{
|
||||||
|
body: { padding: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
|
||||||
|
<IconRocket size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Set Up Your Organization</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Let's get the essentials configured so you can start managing your HOA finances.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box px="xl" py="lg">
|
||||||
|
<Stepper active={active} size="sm" mb="xl">
|
||||||
|
<Stepper.Step
|
||||||
|
label="Operating Account"
|
||||||
|
description="Set up your primary bank account"
|
||||||
|
icon={<IconBuildingBank size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label="Assessment Group"
|
||||||
|
description="Define homeowner assessments"
|
||||||
|
icon={<IconUsers size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label="Budget"
|
||||||
|
description="Import your annual budget"
|
||||||
|
icon={<IconFileSpreadsheet size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Create Operating Account ── */}
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
This is your HOA's main bank account for day-to-day operations. You can add more accounts later.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{accountCreated ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>{accountName} created successfully!</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={2} mb="md">
|
||||||
|
<TextInput
|
||||||
|
label="Account Name"
|
||||||
|
placeholder="e.g. Operating Checking"
|
||||||
|
value={accountName}
|
||||||
|
onChange={(e) => setAccountName(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Account Number"
|
||||||
|
placeholder="e.g. 1000"
|
||||||
|
value={accountNumber}
|
||||||
|
onChange={(e) => setAccountNumber(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={accountDescription}
|
||||||
|
onChange={(e) => setAccountDescription(e.currentTarget.value)}
|
||||||
|
mb="md"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Current Balance"
|
||||||
|
description="Enter the current balance of this bank account"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={initialBalance}
|
||||||
|
onChange={setInitialBalance}
|
||||||
|
thousandSeparator=","
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
mb="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateAccount}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconBuildingBank size={16} />}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Assessment Group + Units ── */}
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Create an Assessment Group</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{groupCreated ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>{groupName} created successfully!</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
|
||||||
|
{unitsCreated && ` with ${units.length} unit(s)`}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={3} mb="md">
|
||||||
|
<TextInput
|
||||||
|
label="Group Name"
|
||||||
|
placeholder="e.g. Standard Assessment"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Assessment Amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={regularAssessment}
|
||||||
|
onChange={setRegularAssessment}
|
||||||
|
thousandSeparator=","
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Frequency"
|
||||||
|
value={frequency}
|
||||||
|
onChange={(v) => setFrequency(v || 'monthly')}
|
||||||
|
data={[
|
||||||
|
{ value: 'monthly', label: 'Monthly' },
|
||||||
|
{ value: 'quarterly', label: 'Quarterly' },
|
||||||
|
{ value: 'annual', label: 'Annual' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
|
||||||
|
|
||||||
|
{units.length > 0 && (
|
||||||
|
<Table mb="md" striped withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Unit Number</Table.Th>
|
||||||
|
<Table.Th>Owner Name</Table.Th>
|
||||||
|
<Table.Th>Owner Email</Table.Th>
|
||||||
|
<Table.Th w={40}></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{units.map((unit, idx) => (
|
||||||
|
<Table.Tr key={idx}>
|
||||||
|
<Table.Td>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="e.g. 101"
|
||||||
|
value={unit.unitNumber}
|
||||||
|
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="John Smith"
|
||||||
|
value={unit.ownerName}
|
||||||
|
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
value={unit.ownerEmail}
|
||||||
|
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={addUnit}
|
||||||
|
>
|
||||||
|
Add Unit
|
||||||
|
</Button>
|
||||||
|
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateGroup}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconUsers size={16} />}
|
||||||
|
>
|
||||||
|
Create Assessment Group
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Budget Upload ── */}
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Upload a CSV file with your annual budget. If you don't have one ready, you can download a template
|
||||||
|
or skip this step and set it up later from the Budgets page.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{budgetUploaded ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>Budget imported successfully!</Text>
|
||||||
|
{budgetImportResult && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
>
|
||||||
|
Download CSV Template
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label="Upload Budget CSV"
|
||||||
|
placeholder="Click to select a .csv file"
|
||||||
|
accept=".csv"
|
||||||
|
value={budgetFile}
|
||||||
|
onChange={setBudgetFile}
|
||||||
|
mb="md"
|
||||||
|
leftSection={<IconFileSpreadsheet size={16} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleUploadBudget}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconFileSpreadsheet size={16} />}
|
||||||
|
disabled={!budgetFile}
|
||||||
|
>
|
||||||
|
Import Budget
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Completion Screen ── */}
|
||||||
|
{active === 3 && (
|
||||||
|
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||||
|
<IconCheck size={32} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} mb="xs">You're All Set!</Title>
|
||||||
|
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||||
|
Your organization is configured and ready to go. You can always update your accounts,
|
||||||
|
assessment groups, and budgets from the sidebar navigation.
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||||
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
|
<IconBuildingBank size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color="green" size="sm">Done</Badge>
|
||||||
|
<Text size="xs" mt={4}>Account</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
|
<IconUsers size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color="green" size="sm">Done</Badge>
|
||||||
|
<Text size="xs" mt={4}>Assessments</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
|
<IconFileSpreadsheet size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
|
||||||
|
{budgetUploaded ? 'Done' : 'Skipped'}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" mt={4}>Budget</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleFinish}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconRocket size={18} />}
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
|
>
|
||||||
|
Start Using LedgerIQ
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Navigation Buttons ── */}
|
||||||
|
{active < 3 && (
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
{active === 2 && !budgetUploaded && (
|
||||||
|
<Button variant="subtle" onClick={nextStep}>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={!canGoNext()}
|
||||||
|
>
|
||||||
|
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/config/tourSteps.ts
Normal file
68
frontend/src/config/tourSteps.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* How-To Intro Tour Steps
|
||||||
|
*
|
||||||
|
* Centralized configuration for the react-joyride walkthrough.
|
||||||
|
* Edit the title and content fields below to change tour text.
|
||||||
|
* Steps are ordered to mirror the natural workflow of the platform.
|
||||||
|
*/
|
||||||
|
import type { Step } from 'react-joyride';
|
||||||
|
|
||||||
|
export const TOUR_STEPS: Step[] = [
|
||||||
|
{
|
||||||
|
target: '[data-tour="dashboard-content"]',
|
||||||
|
title: 'Your Financial Dashboard',
|
||||||
|
content:
|
||||||
|
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
|
||||||
|
placement: 'center',
|
||||||
|
disableBeacon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="sidebar-nav"]',
|
||||||
|
title: 'Navigation',
|
||||||
|
content:
|
||||||
|
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-accounts"]',
|
||||||
|
title: 'Chart of Accounts',
|
||||||
|
content:
|
||||||
|
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-assessment-groups"]',
|
||||||
|
title: 'Assessments & Homeowners',
|
||||||
|
content:
|
||||||
|
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-transactions"]',
|
||||||
|
title: 'Transactions & Journal Entries',
|
||||||
|
content:
|
||||||
|
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-budgets"]',
|
||||||
|
title: 'Budget Management',
|
||||||
|
content:
|
||||||
|
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-reports"]',
|
||||||
|
title: 'Financial Reports',
|
||||||
|
content:
|
||||||
|
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-investment-planning"]',
|
||||||
|
title: 'AI Investment Planning',
|
||||||
|
content:
|
||||||
|
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} 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';
|
||||||
|
|
||||||
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ export function AccountsPage() {
|
|||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ── Accounts query ──
|
// ── Accounts query ──
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
@@ -434,14 +436,44 @@ export function AccountsPage() {
|
|||||||
// Net position = assets + investments - liabilities
|
// Net position = assets + investments - liabilities
|
||||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||||
|
|
||||||
// ── Estimated monthly interest across all accounts with rates ──
|
// ── Estimated monthly interest across all accounts + investments with rates ──
|
||||||
const estMonthlyInterest = accounts
|
const acctMonthlyInterest = accounts
|
||||||
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||||
.reduce((sum, a) => {
|
.reduce((sum, a) => {
|
||||||
const bal = parseFloat(a.balance || '0');
|
const bal = parseFloat(a.balance || '0');
|
||||||
const rate = parseFloat(a.interest_rate || '0');
|
const rate = parseFloat(a.interest_rate || '0');
|
||||||
return sum + (bal * (rate / 100) / 12);
|
return sum + (bal * (rate / 100) / 12);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
const invMonthlyInterest = investments
|
||||||
|
.filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0)
|
||||||
|
.reduce((sum, i) => {
|
||||||
|
const val = parseFloat(i.current_value || i.principal || '0');
|
||||||
|
const rate = parseFloat(i.interest_rate || '0');
|
||||||
|
return sum + (val * (rate / 100) / 12);
|
||||||
|
}, 0);
|
||||||
|
const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest;
|
||||||
|
|
||||||
|
// ── Per-fund cash and interest breakdowns ──
|
||||||
|
const operatingCash = accounts
|
||||||
|
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating')
|
||||||
|
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||||
|
const reserveCash = accounts
|
||||||
|
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve')
|
||||||
|
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||||
|
const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
const opMonthlyInterest = accounts
|
||||||
|
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0)
|
||||||
|
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
|
||||||
|
+ operatingInvestments
|
||||||
|
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
|
||||||
|
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
|
||||||
|
const resMonthlyInterest = accounts
|
||||||
|
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0)
|
||||||
|
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
|
||||||
|
+ reserveInvestments
|
||||||
|
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
|
||||||
|
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
|
||||||
|
|
||||||
// ── Adjust modal: current balance from trial balance ──
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
const adjustCurrentBalance = adjustingAccount
|
const adjustCurrentBalance = adjustingAccount
|
||||||
@@ -472,37 +504,35 @@ export function AccountsPage() {
|
|||||||
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
{!isReadOnly && (
|
||||||
Add Account
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
</Button>
|
Add Account
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Cash on Hand</Text>
|
<Text size="xs" c="dimmed">Operating Fund</Text>
|
||||||
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text>
|
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
|
||||||
|
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
|
||||||
</Card>
|
</Card>
|
||||||
{investmentTotal > 0 && (
|
|
||||||
<Card withBorder p="xs">
|
|
||||||
<Text size="xs" c="dimmed">Investments</Text>
|
|
||||||
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{(totalsByType['liability'] || 0) > 0 && (
|
|
||||||
<Card withBorder p="xs">
|
|
||||||
<Text size="xs" c="dimmed">Liabilities</Text>
|
|
||||||
<Text fw={700} size="sm" c="red">{fmt(totalsByType['liability'] || 0)}</Text>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Net Position</Text>
|
<Text size="xs" c="dimmed">Reserve Fund</Text>
|
||||||
|
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
|
||||||
|
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="xs">
|
||||||
|
<Text size="xs" c="dimmed">Total All Funds</Text>
|
||||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
{estMonthlyInterest > 0 && (
|
{estMonthlyInterest > 0 && (
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||||
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -552,7 +582,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -570,7 +600,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -588,7 +618,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -606,7 +636,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
isArchivedView
|
isArchivedView
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -908,6 +938,7 @@ function AccountTable({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onSetPrimary,
|
onSetPrimary,
|
||||||
onAdjustBalance,
|
onAdjustBalance,
|
||||||
|
isReadOnly = false,
|
||||||
isArchivedView = false,
|
isArchivedView = false,
|
||||||
}: {
|
}: {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
@@ -915,6 +946,7 @@ function AccountTable({
|
|||||||
onArchive: (a: Account) => void;
|
onArchive: (a: Account) => void;
|
||||||
onSetPrimary: (id: string) => void;
|
onSetPrimary: (id: string) => void;
|
||||||
onAdjustBalance: (a: Account) => void;
|
onAdjustBalance: (a: Account) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
isArchivedView?: boolean;
|
isArchivedView?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
||||||
@@ -1003,42 +1035,44 @@ function AccountTable({
|
|||||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
{!isReadOnly && (
|
||||||
{!a.is_system && (
|
<Group gap={4}>
|
||||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
{!a.is_system && (
|
||||||
<ActionIcon
|
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="yellow"
|
variant="subtle"
|
||||||
onClick={() => onSetPrimary(a.id)}
|
color="yellow"
|
||||||
>
|
onClick={() => onSetPrimary(a.id)}
|
||||||
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
>
|
||||||
|
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!a.is_system && (
|
||||||
|
<Tooltip label="Adjust Balance">
|
||||||
|
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||||
|
<IconAdjustments size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Edit account">
|
||||||
|
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
{!a.is_system && (
|
||||||
{!a.is_system && (
|
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||||
<Tooltip label="Adjust Balance">
|
<ActionIcon
|
||||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
variant="subtle"
|
||||||
<IconAdjustments size={16} />
|
color={a.is_active ? 'gray' : 'green'}
|
||||||
</ActionIcon>
|
onClick={() => onArchive(a)}
|
||||||
</Tooltip>
|
>
|
||||||
)}
|
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||||
<Tooltip label="Edit account">
|
</ActionIcon>
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
</Tooltip>
|
||||||
<IconEdit size={16} />
|
)}
|
||||||
</ActionIcon>
|
</Group>
|
||||||
</Tooltip>
|
)}
|
||||||
{!a.is_system && (
|
|
||||||
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={a.is_active ? 'gray' : 'green'}
|
|
||||||
onClick={() => onArchive(a)}
|
|
||||||
>
|
|
||||||
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
@@ -1090,6 +1124,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th>Name</Table.Th>
|
<Table.Th>Name</Table.Th>
|
||||||
<Table.Th>Institution</Table.Th>
|
<Table.Th>Institution</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
<Table.Th ta="right">Principal</Table.Th>
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
<Table.Th ta="right">Current Value</Table.Th>
|
<Table.Th ta="right">Current Value</Table.Th>
|
||||||
<Table.Th ta="right">Rate</Table.Th>
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
@@ -1103,7 +1138,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{investments.length === 0 && (
|
{investments.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={11}>
|
<Table.Td colSpan={12}>
|
||||||
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -1117,6 +1152,11 @@ function InvestmentMiniTable({
|
|||||||
{inv.investment_type}
|
{inv.investment_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||||
|
{inv.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
|
||||||
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} 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 AssessmentGroup {
|
interface AssessmentGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,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;
|
||||||
@@ -48,10 +52,34 @@ 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);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||||
queryKey: ['assessment-groups'],
|
queryKey: ['assessment-groups'],
|
||||||
@@ -71,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'] });
|
||||||
@@ -119,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 || '',
|
||||||
@@ -126,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();
|
||||||
};
|
};
|
||||||
@@ -136,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' });
|
||||||
|
|
||||||
@@ -147,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 (
|
||||||
@@ -156,9 +213,11 @@ export function AssessmentGroupsPage() {
|
|||||||
<Title order={2}>Assessment Groups</Title>
|
<Title order={2}>Assessment Groups</Title>
|
||||||
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
|
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
{!isReadOnly && (
|
||||||
Add Group
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
</Button>
|
Add Group
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||||
@@ -215,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>
|
||||||
@@ -225,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>
|
||||||
@@ -259,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>
|
||||||
@@ -274,28 +337,30 @@ export function AssessmentGroupsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
{!isReadOnly && (
|
||||||
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
<Group gap={4}>
|
||||||
|
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={g.is_default ? 'yellow' : 'gray'}
|
||||||
|
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
|
||||||
|
disabled={g.is_default}
|
||||||
|
>
|
||||||
|
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={g.is_default ? 'yellow' : 'gray'}
|
color={g.is_active ? 'gray' : 'green'}
|
||||||
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
|
onClick={() => archiveMutation.mutate(g)}
|
||||||
disabled={g.is_default}
|
|
||||||
>
|
>
|
||||||
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
<IconArchive size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Group>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
)}
|
||||||
<IconEdit size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={g.is_active ? 'gray' : 'green'}
|
|
||||||
onClick={() => archiveMutation.mutate(g)}
|
|
||||||
>
|
|
||||||
<IconArchive size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -316,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)})`}
|
||||||
@@ -334,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>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } 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 BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -96,6 +97,7 @@ export function BudgetsPage() {
|
|||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
@@ -236,8 +238,12 @@ export function BudgetsPage() {
|
|||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||||
|
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
|
||||||
|
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
|
||||||
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
||||||
const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
const totalIncome = totalOperatingIncome + totalReserveIncome;
|
||||||
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,24 +259,26 @@ export function BudgetsPage() {
|
|||||||
>
|
>
|
||||||
Download Template
|
Download Template
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!isReadOnly && (<>
|
||||||
variant="outline"
|
<Button
|
||||||
leftSection={<IconUpload size={16} />}
|
variant="outline"
|
||||||
onClick={handleImportCSV}
|
leftSection={<IconUpload size={16} />}
|
||||||
loading={importMutation.isPending}
|
onClick={handleImportCSV}
|
||||||
>
|
loading={importMutation.isPending}
|
||||||
Import CSV
|
>
|
||||||
</Button>
|
Import CSV
|
||||||
<input
|
</Button>
|
||||||
type="file"
|
<input
|
||||||
ref={fileInputRef}
|
type="file"
|
||||||
style={{ display: 'none' }}
|
ref={fileInputRef}
|
||||||
accept=".csv,.txt"
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileChange}
|
accept=".csv,.txt"
|
||||||
/>
|
onChange={handleFileChange}
|
||||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
/>
|
||||||
Save Budget
|
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||||
</Button>
|
Save Budget
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -284,17 +292,23 @@ export function BudgetsPage() {
|
|||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Income</Text>
|
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||||
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
|
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
{totalReserveIncome > 0 && (
|
||||||
|
<Card withBorder p="sm">
|
||||||
|
<Text size="xs" c="dimmed">Reserve Income</Text>
|
||||||
|
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Expenses</Text>
|
<Text size="xs" c="dimmed">Total Expenses</Text>
|
||||||
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Net</Text>
|
<Text size="xs" c="dimmed">Net (Operating)</Text>
|
||||||
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||||
{fmt(totalIncome - totalExpense)}
|
{fmt(totalOperatingIncome - totalExpense)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -384,6 +398,7 @@ export function BudgetsPage() {
|
|||||||
hideControls
|
hideControls
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
min={0}
|
min={0}
|
||||||
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -29,7 +30,7 @@ interface Project {
|
|||||||
fund_source: string;
|
fund_source: string;
|
||||||
funded_percentage: string;
|
funded_percentage: string;
|
||||||
planned_date: string;
|
planned_date: string;
|
||||||
target_year: number;
|
target_year: number | null;
|
||||||
target_month: number;
|
target_month: number;
|
||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -37,6 +38,7 @@ interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FUTURE_YEAR = 9999;
|
const FUTURE_YEAR = 9999;
|
||||||
|
const UNSCHEDULED = -1; // sentinel for projects with no target_year
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
planned: 'blue', approved: 'green', in_progress: 'yellow',
|
planned: 'blue', approved: 'green', in_progress: 'yellow',
|
||||||
@@ -48,7 +50,8 @@ const priorityColor = (p: number) => (p <= 2 ? 'red' : p <= 3 ? 'yellow' : 'gray
|
|||||||
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' });
|
||||||
|
|
||||||
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
|
const yearLabel = (year: number) =>
|
||||||
|
year === FUTURE_YEAR ? 'Future' : year === UNSCHEDULED ? 'Unscheduled' : String(year);
|
||||||
|
|
||||||
const formatPlannedDate = (d: string | null | undefined) => {
|
const formatPlannedDate = (d: string | null | undefined) => {
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
@@ -73,6 +76,9 @@ interface KanbanCardProps {
|
|||||||
|
|
||||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
function KanbanCard({ project, onEdit, onDragStart }: 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
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const isBeyondWindow = project.target_year !== null && project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -104,6 +110,11 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
|
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
|
||||||
P{project.priority}
|
P{project.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isBeyondWindow && (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{project.target_year}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" ff="monospace" fw={500} mb={4}>
|
<Text size="xs" ff="monospace" fw={500} mb={4}>
|
||||||
@@ -144,19 +155,26 @@ function KanbanColumn({
|
|||||||
isDragOver, onDragOverHandler, onDragLeave,
|
isDragOver, onDragOverHandler, onDragLeave,
|
||||||
}: 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 isUnscheduled = year === UNSCHEDULED;
|
||||||
|
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
p="sm"
|
p="sm"
|
||||||
miw={280}
|
miw={useWideLayout ? 580 : 280}
|
||||||
maw={320}
|
maw={useWideLayout ? 640 : 320}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined,
|
backgroundColor: isDragOver
|
||||||
|
? 'var(--mantine-color-blue-0)'
|
||||||
|
: isUnscheduled
|
||||||
|
? 'var(--mantine-color-orange-0)'
|
||||||
|
: undefined,
|
||||||
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',
|
||||||
}}
|
}}
|
||||||
@@ -166,7 +184,12 @@ function KanbanColumn({
|
|||||||
>
|
>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{yearLabel(year)}</Title>
|
<Title order={5}>{yearLabel(year)}</Title>
|
||||||
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
<Group gap={6}>
|
||||||
|
{isUnscheduled && projects.length > 0 && (
|
||||||
|
<Badge size="xs" variant="light" color="orange">needs scheduling</Badge>
|
||||||
|
)}
|
||||||
|
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed" mb="xs">
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
@@ -178,6 +201,16 @@ function KanbanColumn({
|
|||||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
Drop projects here
|
Drop projects here
|
||||||
</Text>
|
</Text>
|
||||||
|
) : useWideLayout ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 'var(--mantine-spacing-xs)',
|
||||||
|
}}>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
||||||
|
))}
|
||||||
|
</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} />
|
||||||
@@ -215,6 +248,7 @@ export function CapitalProjectsPage() {
|
|||||||
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
const printModeRef = useRef(false);
|
const printModeRef = useRef(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
@@ -287,10 +321,10 @@ export function CapitalProjectsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const moveProjectMutation = useMutation({
|
const moveProjectMutation = useMutation({
|
||||||
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => {
|
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number | null; target_month: number }) => {
|
||||||
const payload: Record<string, unknown> = { target_year };
|
const payload: Record<string, unknown> = { target_year };
|
||||||
// Derive planned_date based on the new year
|
// Derive planned_date based on the new year
|
||||||
if (target_year === FUTURE_YEAR) {
|
if (target_year === null || target_year === FUTURE_YEAR) {
|
||||||
payload.planned_date = null;
|
payload.planned_date = null;
|
||||||
} else {
|
} else {
|
||||||
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
|
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
|
||||||
@@ -329,7 +363,7 @@ export function CapitalProjectsPage() {
|
|||||||
form.setValues({
|
form.setValues({
|
||||||
status: p.status || 'planned',
|
status: p.status || 'planned',
|
||||||
priority: p.priority || 3,
|
priority: p.priority || 3,
|
||||||
target_year: p.target_year,
|
target_year: p.target_year ?? currentYear,
|
||||||
target_month: p.target_month || 6,
|
target_month: p.target_month || 6,
|
||||||
planned_date: p.planned_date || '',
|
planned_date: p.planned_date || '',
|
||||||
notes: p.notes || '',
|
notes: p.notes || '',
|
||||||
@@ -352,7 +386,7 @@ export function CapitalProjectsPage() {
|
|||||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
source_year: project.target_year,
|
source_year: project.target_year ?? UNSCHEDULED,
|
||||||
target_month: project.target_month,
|
target_month: project.target_month,
|
||||||
}));
|
}));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -376,7 +410,7 @@ export function CapitalProjectsPage() {
|
|||||||
if (payload.source_year !== targetYear) {
|
if (payload.source_year !== targetYear) {
|
||||||
moveProjectMutation.mutate({
|
moveProjectMutation.mutate({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
target_year: targetYear,
|
target_year: targetYear === UNSCHEDULED ? null : targetYear,
|
||||||
target_month: payload.target_month || 6,
|
target_month: payload.target_month || 6,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -389,15 +423,20 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it
|
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it
|
||||||
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
|
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
|
||||||
const projectYears = [...new Set(projects.map((p) => p.target_year))];
|
const projectYears = [...new Set(projects.map((p) => p.target_year).filter((y): y is number => y !== null))];
|
||||||
const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
|
const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
|
||||||
|
const hasUnscheduledProjects = projects.some((p) => p.target_year === null);
|
||||||
|
|
||||||
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
||||||
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
||||||
const years = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears;
|
const years = [
|
||||||
|
...regularYears,
|
||||||
|
...(hasFutureProjects ? [FUTURE_YEAR] : []),
|
||||||
|
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
|
||||||
|
];
|
||||||
|
|
||||||
// Kanban columns: always current..current+4 plus Future
|
// Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
|
||||||
const kanbanYears = [...baseYears, FUTURE_YEAR];
|
const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
|
||||||
|
|
||||||
// ---- Loading state ----
|
// ---- Loading state ----
|
||||||
|
|
||||||
@@ -417,12 +456,11 @@ export function CapitalProjectsPage() {
|
|||||||
<Stack align="center" gap="md" maw={420}>
|
<Stack align="center" gap="md" maw={420}>
|
||||||
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
|
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
|
||||||
<Title order={3} c="dimmed" ta="center">
|
<Title order={3} c="dimmed" ta="center">
|
||||||
No projects in the capital plan
|
No projects yet
|
||||||
</Title>
|
</Title>
|
||||||
<Text c="dimmed" ta="center" size="sm">
|
<Text c="dimmed" ta="center" size="sm">
|
||||||
Capital Planning displays projects that have a target year assigned.
|
|
||||||
Head over to the Projects page to define your reserve and operating
|
Head over to the Projects page to define your reserve and operating
|
||||||
projects, then assign target years to see them here.
|
projects. They'll appear here for capital planning and scheduling.
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -448,7 +486,9 @@ export function CapitalProjectsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
years.map((year) => {
|
years.map((year) => {
|
||||||
const yearProjects = projects.filter((p) => p.target_year === year);
|
const yearProjects = year === UNSCHEDULED
|
||||||
|
? projects.filter((p) => p.target_year === null)
|
||||||
|
: projects.filter((p) => p.target_year === year);
|
||||||
if (yearProjects.length === 0) return null;
|
if (yearProjects.length === 0) return null;
|
||||||
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
return (
|
return (
|
||||||
@@ -479,16 +519,18 @@ export function CapitalProjectsPage() {
|
|||||||
<Table.Td fw={500}>{p.name}</Table.Td>
|
<Table.Td fw={500}>{p.name}</Table.Td>
|
||||||
<Table.Td>{p.category || '-'}</Table.Td>
|
<Table.Td>{p.category || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{p.target_year === FUTURE_YEAR
|
{p.target_year === null
|
||||||
? 'Future'
|
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
|
||||||
: (
|
: p.target_year === FUTURE_YEAR
|
||||||
<>
|
? 'Future'
|
||||||
{p.target_month
|
: (
|
||||||
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
|
<>
|
||||||
: ''}{' '}
|
{p.target_month
|
||||||
{p.target_year}
|
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
|
||||||
</>
|
: ''}{' '}
|
||||||
)
|
{p.target_year}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -511,9 +553,9 @@ export function CapitalProjectsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
|
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -528,11 +570,20 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// ---- Render: Kanban view ----
|
// ---- Render: Kanban view ----
|
||||||
|
|
||||||
|
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
|
||||||
|
|
||||||
const renderKanbanView = () => (
|
const renderKanbanView = () => (
|
||||||
<ScrollArea type="auto" offsetScrollbars>
|
<ScrollArea type="auto" offsetScrollbars>
|
||||||
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
|
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
|
||||||
{kanbanYears.map((year) => {
|
{kanbanYears.map((year) => {
|
||||||
const yearProjects = projects.filter((p) => p.target_year === year);
|
// Unscheduled: projects with no target_year
|
||||||
|
// Future: projects with target_year === 9999 OR beyond the 5-year window
|
||||||
|
// Otherwise: exact year match
|
||||||
|
const yearProjects = year === UNSCHEDULED
|
||||||
|
? projects.filter((p) => p.target_year === null)
|
||||||
|
: year === FUTURE_YEAR
|
||||||
|
? projects.filter((p) => p.target_year === FUTURE_YEAR || (p.target_year !== null && p.target_year > maxPlannedYear))
|
||||||
|
: projects.filter((p) => p.target_year === year);
|
||||||
return (
|
return (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={year}
|
key={year}
|
||||||
|
|||||||
@@ -1,17 +1,296 @@
|
|||||||
import {
|
import {
|
||||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||||
Badge, Loader, Center,
|
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||||
|
Popover, List,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash,
|
IconCash,
|
||||||
IconFileInvoice,
|
IconFileInvoice,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconBuildingBank,
|
||||||
|
IconTrendingUp,
|
||||||
|
IconTrendingDown,
|
||||||
|
IconMinus,
|
||||||
|
IconHeartbeat,
|
||||||
|
IconRefresh,
|
||||||
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface HealthScore {
|
||||||
|
id: string;
|
||||||
|
score_type: string;
|
||||||
|
score: number;
|
||||||
|
previous_score: number | null;
|
||||||
|
trajectory: string | null;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
|
||||||
|
recommendations: Array<{ priority: string; text: string }>;
|
||||||
|
missing_data: string[] | null;
|
||||||
|
status: string;
|
||||||
|
response_time_ms: number | null;
|
||||||
|
calculated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthScoresData {
|
||||||
|
operating: HealthScore | null;
|
||||||
|
reserve: HealthScore | null;
|
||||||
|
operating_last_failed?: boolean;
|
||||||
|
reserve_last_failed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreColor(score: number): string {
|
||||||
|
if (score >= 75) return 'green';
|
||||||
|
if (score >= 60) return 'yellow';
|
||||||
|
if (score >= 40) return 'orange';
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
||||||
|
if (trajectory === 'improving') return <IconTrendingUp size={16} color="var(--mantine-color-green-6)" />;
|
||||||
|
if (trajectory === 'declining') return <IconTrendingDown size={16} color="var(--mantine-color-red-6)" />;
|
||||||
|
if (trajectory === 'stable') return <IconMinus size={16} color="var(--mantine-color-gray-6)" />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthScoreCard({
|
||||||
|
score,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isRefreshing,
|
||||||
|
onRefresh,
|
||||||
|
lastFailed,
|
||||||
|
}: {
|
||||||
|
score: HealthScore | null;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
lastFailed?: boolean;
|
||||||
|
}) {
|
||||||
|
// No score at all yet
|
||||||
|
if (!score) {
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Center h={100}>
|
||||||
|
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending — missing data, can't calculate
|
||||||
|
if (score.status === 'pending') {
|
||||||
|
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
||||||
|
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Center>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
||||||
|
{missingItems.map((item: string, i: number) => (
|
||||||
|
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For error status, we still render the score data (cached from the previous
|
||||||
|
// successful run) rather than blanking the card with "Error calculating score".
|
||||||
|
// A small watermark under the timestamp tells the user it's stale.
|
||||||
|
const showAsError = score.status === 'error' && score.score === 0 && !score.summary;
|
||||||
|
|
||||||
|
// Pure error with no cached data to fall back on
|
||||||
|
if (showAsError) {
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Retry ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Retry</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Center h={100}>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||||
|
<Text size="xs" c="dimmed">Click Retry to try again</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal display — works for both 'complete' and 'error' (with cached data)
|
||||||
|
const color = getScoreColor(score.score);
|
||||||
|
const factors = Array.isArray(score.factors) ? score.factors :
|
||||||
|
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
||||||
|
const recommendations = Array.isArray(score.recommendations) ? score.recommendations :
|
||||||
|
(typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Group align="flex-start" gap="lg">
|
||||||
|
<RingProgress
|
||||||
|
size={120}
|
||||||
|
thickness={12}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: score.score, color }]}
|
||||||
|
label={
|
||||||
|
<Stack align="center" gap={0}>
|
||||||
|
<Text fw={700} size="xl" ta="center" lh={1}>{score.score}</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">/100</Text>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Badge color={color} variant="light" size="sm">{score.label}</Badge>
|
||||||
|
{score.trajectory && (
|
||||||
|
<Tooltip label={`Trend: ${score.trajectory}`}>
|
||||||
|
<Group gap={2}>
|
||||||
|
<TrajectoryIcon trajectory={score.trajectory} />
|
||||||
|
<Text size="xs" c="dimmed">{score.trajectory}</Text>
|
||||||
|
</Group>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{score.previous_score !== null && (
|
||||||
|
<Text size="xs" c="dimmed">(prev: {score.previous_score})</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" lineClamp={2}>{score.summary}</Text>
|
||||||
|
<Group gap={4} mt={2}>
|
||||||
|
{factors.slice(0, 3).map((f: any, i: number) => (
|
||||||
|
<Tooltip key={i} label={f.detail} multiline w={280}>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="dot"
|
||||||
|
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
{(factors.length > 3 || recommendations.length > 0) && (
|
||||||
|
<Popover width={350} position="bottom" shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<Badge size="xs" variant="light" color="blue" style={{ cursor: 'pointer' }}>
|
||||||
|
<IconInfoCircle size={10} /> Details
|
||||||
|
</Badge>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{factors.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text fw={600} size="xs">Factors</Text>
|
||||||
|
{factors.map((f: any, i: number) => (
|
||||||
|
<Group key={i} gap={6} wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="dot"
|
||||||
|
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">{f.detail}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider my={4} />
|
||||||
|
<Text fw={600} size="xs">Recommendations</Text>
|
||||||
|
<List size="xs" spacing={4}>
|
||||||
|
{recommendations.map((r: any, i: number) => (
|
||||||
|
<List.Item key={i}>
|
||||||
|
<Badge size="xs" color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'} variant="light" mr={4}>
|
||||||
|
{r.priority}
|
||||||
|
</Badge>
|
||||||
|
{r.text}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{score.calculated_at && (
|
||||||
|
<Text size="xs" c="dimmed" ta="right" mt={4}>
|
||||||
|
Updated: {new Date(score.calculated_at).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{score.calculated_at && (
|
||||||
|
<Stack gap={0} mt={6} align="flex-end">
|
||||||
|
<Text size="10px" c="dimmed" style={{ opacity: 0.7 }}>
|
||||||
|
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
{lastFailed && (
|
||||||
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||||
|
last analysis failed — showing cached data
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
total_cash: string;
|
total_cash: string;
|
||||||
total_receivables: string;
|
total_receivables: string;
|
||||||
@@ -20,10 +299,23 @@ interface DashboardData {
|
|||||||
recent_transactions: {
|
recent_transactions: {
|
||||||
id: string; entry_date: string; description: string; entry_type: string; amount: string;
|
id: string; entry_date: string; description: string; entry_type: string; amount: string;
|
||||||
}[];
|
}[];
|
||||||
|
// Enhanced split data
|
||||||
|
operating_cash: string;
|
||||||
|
reserve_cash: string;
|
||||||
|
operating_investments: string;
|
||||||
|
reserve_investments: string;
|
||||||
|
est_monthly_interest: string;
|
||||||
|
interest_earned_ytd: string;
|
||||||
|
planned_capital_spend: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Track whether a refresh is in progress (per score type) for async polling
|
||||||
|
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
||||||
|
const [reserveRefreshing, setReserveRefreshing] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
@@ -31,15 +323,76 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||||
|
queryKey: ['health-scores'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
|
enabled: !!currentOrg,
|
||||||
|
// Poll every 3 seconds while a refresh is in progress
|
||||||
|
refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Async refresh handlers — trigger the backend and poll for results
|
||||||
|
const handleRefreshOperating = useCallback(async () => {
|
||||||
|
const prevId = healthScores?.operating?.id;
|
||||||
|
setOperatingRefreshing(true);
|
||||||
|
try {
|
||||||
|
await api.post('/health-scores/calculate/operating');
|
||||||
|
} catch {
|
||||||
|
// Trigger failed at network level — polling will pick up any backend-saved error
|
||||||
|
}
|
||||||
|
// Start polling — watch for the health score to change (new id or updated timestamp)
|
||||||
|
const pollUntilDone = () => {
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { data: latest } = await api.get('/health-scores/latest');
|
||||||
|
const newScore = latest?.operating;
|
||||||
|
if (newScore && newScore.id !== prevId) {
|
||||||
|
setOperatingRefreshing(false);
|
||||||
|
queryClient.setQueryData(['health-scores'], latest);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
// Safety timeout — stop polling after 11 minutes
|
||||||
|
setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000);
|
||||||
|
};
|
||||||
|
pollUntilDone();
|
||||||
|
}, [healthScores?.operating?.id, queryClient]);
|
||||||
|
|
||||||
|
const handleRefreshReserve = useCallback(async () => {
|
||||||
|
const prevId = healthScores?.reserve?.id;
|
||||||
|
setReserveRefreshing(true);
|
||||||
|
try {
|
||||||
|
await api.post('/health-scores/calculate/reserve');
|
||||||
|
} catch {
|
||||||
|
// Trigger failed at network level
|
||||||
|
}
|
||||||
|
const pollUntilDone = () => {
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { data: latest } = await api.get('/health-scores/latest');
|
||||||
|
const newScore = latest?.reserve;
|
||||||
|
if (newScore && newScore.id !== prevId) {
|
||||||
|
setReserveRefreshing(false);
|
||||||
|
queryClient.setQueryData(['health-scores'], latest);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000);
|
||||||
|
};
|
||||||
|
pollUntilDone();
|
||||||
|
}, [healthScores?.reserve?.id, queryClient]);
|
||||||
|
|
||||||
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' });
|
||||||
|
|
||||||
const stats = [
|
const opInv = parseFloat(data?.operating_investments || '0');
|
||||||
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
|
const resInv = parseFloat(data?.reserve_investments || '0');
|
||||||
{ title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' },
|
|
||||||
{ title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' },
|
|
||||||
{ title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const entryTypeColors: Record<string, string> = {
|
const entryTypeColors: Record<string, string> = {
|
||||||
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
||||||
@@ -47,13 +400,8 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack data-tour="dashboard-content">
|
||||||
<div>
|
<Title order={2}>Dashboard</Title>
|
||||||
<Title order={2}>Dashboard</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!currentOrg ? (
|
{!currentOrg ? (
|
||||||
<Card withBorder p="xl" ta="center">
|
<Card withBorder p="xl" ta="center">
|
||||||
@@ -66,24 +414,80 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
|
<HealthScoreCard
|
||||||
|
score={healthScores?.operating || null}
|
||||||
|
title="Operating Fund"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon color="green" variant="light" size={36} radius="md">
|
||||||
|
<IconHeartbeat size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
isRefreshing={operatingRefreshing}
|
||||||
|
onRefresh={handleRefreshOperating}
|
||||||
|
lastFailed={!!healthScores?.operating_last_failed}
|
||||||
|
/>
|
||||||
|
<HealthScoreCard
|
||||||
|
score={healthScores?.reserve || null}
|
||||||
|
title="Reserve Fund"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
||||||
|
<IconHeartbeat size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
isRefreshing={reserveRefreshing}
|
||||||
|
onRefresh={handleRefreshReserve}
|
||||||
|
lastFailed={!!healthScores?.reserve_last_failed}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
{stats.map((stat) => (
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
<Group justify="space-between">
|
||||||
<Group justify="space-between">
|
<div>
|
||||||
<div>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
{stat.title}
|
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
|
||||||
</Text>
|
</div>
|
||||||
<Text fw={700} size="xl">
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||||
{stat.value}
|
<IconCash size={28} />
|
||||||
</Text>
|
</ThemeIcon>
|
||||||
</div>
|
</Group>
|
||||||
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
|
</Card>
|
||||||
<stat.icon size={28} />
|
<Card withBorder padding="lg" radius="md">
|
||||||
</ThemeIcon>
|
<Group justify="space-between">
|
||||||
</Group>
|
<div>
|
||||||
</Card>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
|
||||||
))}
|
<Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
|
||||||
|
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
||||||
|
<IconShieldCheck size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
|
||||||
|
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||||
|
<IconFileInvoice size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
|
||||||
|
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="orange" variant="light" size={48} radius="md">
|
||||||
|
<IconAlertTriangle size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
@@ -120,17 +524,31 @@ export function DashboardPage() {
|
|||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
<Stack mt="sm" gap="xs">
|
<Stack mt="sm" gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Cash Position</Text>
|
<Text size="sm" c="dimmed">Operating Cash</Text>
|
||||||
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
|
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Reserve Cash</Text>
|
||||||
|
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider my={4} />
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
|
||||||
|
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
|
||||||
|
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
|
||||||
|
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider my={4} />
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
||||||
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Reserve Funding</Text>
|
|
||||||
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
||||||
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Accordion,
|
Accordion,
|
||||||
Paper,
|
Paper,
|
||||||
|
Tabs,
|
||||||
|
Collapse,
|
||||||
|
ActionIcon,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBulb,
|
IconBulb,
|
||||||
@@ -27,8 +30,10 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconCoin,
|
IconCoin,
|
||||||
IconPigMoney,
|
IconPigMoney,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation } 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';
|
||||||
|
|
||||||
@@ -59,15 +64,22 @@ interface FinancialSnapshot {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CdRate {
|
interface MarketRate {
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
apy: string;
|
apy: string;
|
||||||
min_deposit: string | null;
|
min_deposit: string | null;
|
||||||
term: string;
|
term: string;
|
||||||
term_months: number | null;
|
term_months: number | null;
|
||||||
|
rate_type: string;
|
||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarketRatesResponse {
|
||||||
|
cd: MarketRate[];
|
||||||
|
money_market: MarketRate[];
|
||||||
|
high_yield_savings: MarketRate[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
type: string;
|
type: string;
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
@@ -88,6 +100,18 @@ interface AIResponse {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SavedRecommendation {
|
||||||
|
id: string;
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
overall_assessment: string;
|
||||||
|
risk_notes: string[];
|
||||||
|
response_time_ms: number;
|
||||||
|
created_at: string;
|
||||||
|
status: 'processing' | 'complete' | 'error';
|
||||||
|
last_failed: boolean;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
const fmt = (v: number) =>
|
const fmt = (v: number) =>
|
||||||
@@ -117,10 +141,212 @@ const typeLabels: Record<string, string> = {
|
|||||||
general: 'General',
|
general: 'General',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Component ──
|
// ── Rate Table Component ──
|
||||||
|
|
||||||
|
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
|
||||||
|
if (rates.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No rates available. Run the market rate fetcher to populate data.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Bank</Table.Th>
|
||||||
|
<Table.Th ta="right">APY</Table.Th>
|
||||||
|
{showTerm && <Table.Th>Term</Table.Th>}
|
||||||
|
<Table.Th ta="right">Min Deposit</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rates.map((r, i) => (
|
||||||
|
<Table.Tr key={i}>
|
||||||
|
<Table.Td fw={500}>{r.bank_name}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} c="green">
|
||||||
|
{parseFloat(r.apy).toFixed(2)}%
|
||||||
|
</Table.Td>
|
||||||
|
{showTerm && <Table.Td>{r.term}</Table.Td>}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{r.min_deposit
|
||||||
|
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
||||||
|
: '-'}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recommendations Display Component ──
|
||||||
|
|
||||||
|
function RecommendationsDisplay({
|
||||||
|
aiResult,
|
||||||
|
lastUpdated,
|
||||||
|
lastFailed,
|
||||||
|
}: {
|
||||||
|
aiResult: AIResponse;
|
||||||
|
lastUpdated?: string;
|
||||||
|
lastFailed?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Last Updated timestamp + failure message */}
|
||||||
|
{lastUpdated && (
|
||||||
|
<Stack gap={0} align="flex-end">
|
||||||
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
{lastFailed && (
|
||||||
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||||
|
last analysis failed — showing cached data
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overall Assessment */}
|
||||||
|
<Alert color="blue" variant="light" title="Overall Assessment">
|
||||||
|
<Text size="sm">{aiResult.overall_assessment}</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Risk Notes */}
|
||||||
|
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
title="Risk Notes"
|
||||||
|
icon={<IconAlertTriangle />}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
{aiResult.risk_notes.map((note, i) => (
|
||||||
|
<Text key={i} size="sm">
|
||||||
|
{note}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendation Cards */}
|
||||||
|
{aiResult.recommendations.length > 0 ? (
|
||||||
|
<Accordion variant="separated">
|
||||||
|
{aiResult.recommendations.map((rec, i) => {
|
||||||
|
const Icon = typeIcons[rec.type] || IconBulb;
|
||||||
|
return (
|
||||||
|
<Accordion.Item key={i} value={`rec-${i}`}>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={priorityColors[rec.priority] || 'gray'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600}>{rec.title}</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color={priorityColors[rec.priority]}
|
||||||
|
>
|
||||||
|
{rec.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge size="xs" variant="light">
|
||||||
|
{typeLabels[rec.type] || rec.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="dot"
|
||||||
|
color={
|
||||||
|
rec.fund_type === 'reserve'
|
||||||
|
? 'violet'
|
||||||
|
: rec.fund_type === 'operating'
|
||||||
|
? 'blue'
|
||||||
|
: 'gray'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rec.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" mt={2}>
|
||||||
|
{rec.summary}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{rec.suggested_amount != null && (
|
||||||
|
<Text fw={700} ff="monospace" c="green" size="lg">
|
||||||
|
{fmt(rec.suggested_amount)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{rec.details}</Text>
|
||||||
|
|
||||||
|
{(rec.suggested_term ||
|
||||||
|
rec.suggested_rate != null ||
|
||||||
|
rec.bank_name) && (
|
||||||
|
<Paper withBorder p="sm" radius="sm">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
{rec.suggested_term && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Suggested Term
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{rec.suggested_term}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.suggested_rate != null && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Target Rate
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>
|
||||||
|
{rec.suggested_rate}% APY
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.bank_name && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Bank
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{rec.bank_name}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert variant="light" color="gray" title="Rationale">
|
||||||
|
<Text size="sm">{rec.rationale}</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
) : (
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No specific recommendations at this time.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──
|
||||||
|
|
||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
|
const [isTriggering, setIsTriggering] = useState(false);
|
||||||
|
|
||||||
// Load financial snapshot on mount
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -131,38 +357,96 @@ export function InvestmentPlanningPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load CD rates on mount
|
// Load market rates (all types) on mount
|
||||||
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({
|
const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
|
||||||
queryKey: ['investment-planning-cd-rates'],
|
queryKey: ['investment-planning-market-rates'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/investment-planning/cd-rates');
|
const { data } = await api.get('/investment-planning/market-rates');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// AI recommendation (on-demand)
|
// Load saved recommendation — polls every 3s when processing
|
||||||
const aiMutation = useMutation({
|
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||||
mutationFn: async () => {
|
queryKey: ['investment-planning-saved-recommendation'],
|
||||||
const { data } = await api.post('/investment-planning/recommendations');
|
queryFn: async () => {
|
||||||
return data as AIResponse;
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
refetchInterval: (query) => {
|
||||||
setAiResult(data);
|
const rec = query.state.data;
|
||||||
if (data.recommendations.length > 0) {
|
// Poll every 3 seconds while processing
|
||||||
notifications.show({
|
if (rec?.status === 'processing') return 3000;
|
||||||
message: `Generated ${data.recommendations.length} investment recommendations`,
|
// Also poll if we just triggered (status may not be 'processing' yet)
|
||||||
color: 'green',
|
if (isTriggering) return 3000;
|
||||||
});
|
return false;
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
notifications.show({
|
|
||||||
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
|
||||||
color: 'red',
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Derive display state from saved recommendation
|
||||||
|
const isProcessing = savedRec?.status === 'processing' || isTriggering;
|
||||||
|
const lastFailed = savedRec?.last_failed || false;
|
||||||
|
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
|
||||||
|
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
|
||||||
|
|
||||||
|
// Clear triggering flag once backend confirms processing or completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTriggering && savedRec?.status === 'processing') {
|
||||||
|
setIsTriggering(false);
|
||||||
|
}
|
||||||
|
if (isTriggering && savedRec?.status === 'complete') {
|
||||||
|
setIsTriggering(false);
|
||||||
|
}
|
||||||
|
}, [savedRec?.status, isTriggering]);
|
||||||
|
|
||||||
|
// Show notification when processing completes (transition from processing)
|
||||||
|
const prevStatusRef = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const [prevStatus, setPrevStatus] = prevStatusRef;
|
||||||
|
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
||||||
|
notifications.show({
|
||||||
|
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
||||||
|
notifications.show({
|
||||||
|
message: savedRec.error_message || 'AI recommendation analysis failed',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setPrevStatus(savedRec?.status || null);
|
||||||
|
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Trigger AI recommendations (async — returns immediately)
|
||||||
|
const handleTriggerAI = useCallback(async () => {
|
||||||
|
setIsTriggering(true);
|
||||||
|
try {
|
||||||
|
await api.post('/investment-planning/recommendations');
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsTriggering(false);
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Failed to start AI analysis',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build AI result from saved recommendation for display
|
||||||
|
const aiResult: AIResponse | null = hasResults
|
||||||
|
? {
|
||||||
|
recommendations: savedRec!.recommendations,
|
||||||
|
overall_assessment: savedRec!.overall_assessment,
|
||||||
|
risk_notes: savedRec!.risk_notes,
|
||||||
|
}
|
||||||
|
: (lastFailed && savedRec?.recommendations?.length)
|
||||||
|
? {
|
||||||
|
recommendations: savedRec!.recommendations,
|
||||||
|
overall_assessment: savedRec!.overall_assessment,
|
||||||
|
risk_notes: savedRec!.risk_notes,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
if (snapshotLoading) {
|
if (snapshotLoading) {
|
||||||
return (
|
return (
|
||||||
<Center h={400}>
|
<Center h={400}>
|
||||||
@@ -173,6 +457,23 @@ export function InvestmentPlanningPage() {
|
|||||||
|
|
||||||
const s = snapshot?.summary;
|
const s = snapshot?.summary;
|
||||||
|
|
||||||
|
// Determine the latest fetched_at timestamp across all rate types
|
||||||
|
const allRatesList = [
|
||||||
|
...(marketRates?.cd || []),
|
||||||
|
...(marketRates?.money_market || []),
|
||||||
|
...(marketRates?.high_yield_savings || []),
|
||||||
|
];
|
||||||
|
const latestFetchedAt = allRatesList.length > 0
|
||||||
|
? allRatesList.reduce((latest, r) =>
|
||||||
|
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
|
||||||
|
).fetched_at
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const totalRateCount =
|
||||||
|
(marketRates?.cd?.length || 0) +
|
||||||
|
(marketRates?.money_market?.length || 0) +
|
||||||
|
(marketRates?.high_yield_savings?.length || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -312,57 +613,71 @@ export function InvestmentPlanningPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Section 3: Market CD Rates ── */}
|
{/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
|
||||||
<Title order={4}>Market CD Rates</Title>
|
<Group gap="xs">
|
||||||
{cdRates.length > 0 && (
|
<Title order={4}>Today's Market Rates</Title>
|
||||||
<Text size="xs" c="dimmed">
|
{totalRateCount > 0 && (
|
||||||
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()}
|
<Badge size="sm" variant="light" color="gray">
|
||||||
</Text>
|
{totalRateCount} rates
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
{latestFetchedAt && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setRatesExpanded((v) => !v)}
|
||||||
|
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
|
||||||
|
>
|
||||||
|
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
{ratesLoading ? (
|
|
||||||
<Center py="lg">
|
<Collapse in={ratesExpanded}>
|
||||||
<Loader />
|
{ratesLoading ? (
|
||||||
</Center>
|
<Center py="lg">
|
||||||
) : (
|
<Loader />
|
||||||
<Table striped highlightOnHover>
|
</Center>
|
||||||
<Table.Thead>
|
) : (
|
||||||
<Table.Tr>
|
<Tabs defaultValue="cd">
|
||||||
<Table.Th>Bank</Table.Th>
|
<Tabs.List>
|
||||||
<Table.Th ta="right">APY</Table.Th>
|
<Tabs.Tab value="cd">
|
||||||
<Table.Th>Term</Table.Th>
|
CDs {(marketRates?.cd?.length || 0) > 0 && (
|
||||||
<Table.Th ta="right">Min Deposit</Table.Th>
|
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
|
||||||
</Table.Tr>
|
)}
|
||||||
</Table.Thead>
|
</Tabs.Tab>
|
||||||
<Table.Tbody>
|
<Tabs.Tab value="money_market">
|
||||||
{cdRates.map((r, i) => (
|
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
|
||||||
<Table.Tr key={i}>
|
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
|
||||||
<Table.Td fw={500}>{r.bank_name}</Table.Td>
|
)}
|
||||||
<Table.Td ta="right" fw={700} c="green">
|
</Tabs.Tab>
|
||||||
{parseFloat(r.apy).toFixed(2)}%
|
<Tabs.Tab value="high_yield_savings">
|
||||||
</Table.Td>
|
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
|
||||||
<Table.Td>{r.term}</Table.Td>
|
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
|
||||||
<Table.Td ta="right" ff="monospace">
|
)}
|
||||||
{r.min_deposit
|
</Tabs.Tab>
|
||||||
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
</Tabs.List>
|
||||||
: '-'}
|
|
||||||
</Table.Td>
|
<Tabs.Panel value="cd" pt="sm">
|
||||||
</Table.Tr>
|
<RateTable rates={marketRates?.cd || []} showTerm={true} />
|
||||||
))}
|
</Tabs.Panel>
|
||||||
{cdRates.length === 0 && (
|
<Tabs.Panel value="money_market" pt="sm">
|
||||||
<Table.Tr>
|
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
|
||||||
<Table.Td colSpan={4}>
|
</Tabs.Panel>
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<Tabs.Panel value="high_yield_savings" pt="sm">
|
||||||
No CD rates available. Run the fetch-cd-rates script to populate market data.
|
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
|
||||||
</Text>
|
</Tabs.Panel>
|
||||||
</Table.Td>
|
</Tabs>
|
||||||
</Table.Tr>
|
)}
|
||||||
)}
|
</Collapse>
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -383,8 +698,8 @@ export function InvestmentPlanningPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconSparkles size={16} />}
|
leftSection={<IconSparkles size={16} />}
|
||||||
onClick={() => aiMutation.mutate()}
|
onClick={handleTriggerAI}
|
||||||
loading={aiMutation.isPending}
|
loading={isProcessing}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: 'grape', to: 'violet' }}
|
gradient={{ from: 'grape', to: 'violet' }}
|
||||||
>
|
>
|
||||||
@@ -392,8 +707,8 @@ export function InvestmentPlanningPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Processing State */}
|
||||||
{aiMutation.isPending && (
|
{isProcessing && (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<Loader size="lg" type="dots" />
|
<Loader size="lg" type="dots" />
|
||||||
@@ -401,149 +716,32 @@ export function InvestmentPlanningPage() {
|
|||||||
Analyzing your financial data and market rates...
|
Analyzing your financial data and market rates...
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
This may take up to 30 seconds
|
You can navigate away — results will appear when ready
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Error State (no cached data) */}
|
||||||
{aiResult && !aiMutation.isPending && (
|
{hasError && !isProcessing && (
|
||||||
<Stack>
|
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
|
||||||
{/* Overall Assessment */}
|
<Text size="sm">
|
||||||
<Alert color="blue" variant="light" title="Overall Assessment">
|
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
|
||||||
<Text size="sm">{aiResult.overall_assessment}</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Risk Notes */}
|
{/* Results (with optional failure watermark) */}
|
||||||
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
|
{aiResult && !isProcessing && (
|
||||||
<Alert
|
<RecommendationsDisplay
|
||||||
color="yellow"
|
aiResult={aiResult}
|
||||||
variant="light"
|
lastUpdated={savedRec?.created_at || undefined}
|
||||||
title="Risk Notes"
|
lastFailed={lastFailed}
|
||||||
icon={<IconAlertTriangle />}
|
/>
|
||||||
>
|
|
||||||
<Stack gap={4}>
|
|
||||||
{aiResult.risk_notes.map((note, i) => (
|
|
||||||
<Text key={i} size="sm">
|
|
||||||
{note}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recommendation Cards */}
|
|
||||||
{aiResult.recommendations.length > 0 ? (
|
|
||||||
<Accordion variant="separated">
|
|
||||||
{aiResult.recommendations.map((rec, i) => {
|
|
||||||
const Icon = typeIcons[rec.type] || IconBulb;
|
|
||||||
return (
|
|
||||||
<Accordion.Item key={i} value={`rec-${i}`}>
|
|
||||||
<Accordion.Control>
|
|
||||||
<Group>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={priorityColors[rec.priority] || 'gray'}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Text fw={600}>{rec.title}</Text>
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
color={priorityColors[rec.priority]}
|
|
||||||
>
|
|
||||||
{rec.priority}
|
|
||||||
</Badge>
|
|
||||||
<Badge size="xs" variant="light">
|
|
||||||
{typeLabels[rec.type] || rec.type}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="dot"
|
|
||||||
color={
|
|
||||||
rec.fund_type === 'reserve'
|
|
||||||
? 'violet'
|
|
||||||
: rec.fund_type === 'operating'
|
|
||||||
? 'blue'
|
|
||||||
: 'gray'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rec.fund_type}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed" mt={2}>
|
|
||||||
{rec.summary}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{rec.suggested_amount != null && (
|
|
||||||
<Text fw={700} ff="monospace" c="green" size="lg">
|
|
||||||
{fmt(rec.suggested_amount)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Accordion.Control>
|
|
||||||
<Accordion.Panel>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm">{rec.details}</Text>
|
|
||||||
|
|
||||||
{(rec.suggested_term ||
|
|
||||||
rec.suggested_rate != null ||
|
|
||||||
rec.bank_name) && (
|
|
||||||
<Paper withBorder p="sm" radius="sm">
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
|
||||||
{rec.suggested_term && (
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Suggested Term
|
|
||||||
</Text>
|
|
||||||
<Text fw={600}>{rec.suggested_term}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rec.suggested_rate != null && (
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Target Rate
|
|
||||||
</Text>
|
|
||||||
<Text fw={600}>
|
|
||||||
{rec.suggested_rate}% APY
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rec.bank_name && (
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Bank
|
|
||||||
</Text>
|
|
||||||
<Text fw={600}>{rec.bank_name}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert variant="light" color="gray" title="Rationale">
|
|
||||||
<Text size="sm">{rec.rationale}</Text>
|
|
||||||
</Alert>
|
|
||||||
</Stack>
|
|
||||||
</Accordion.Panel>
|
|
||||||
</Accordion.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Accordion>
|
|
||||||
) : (
|
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
|
||||||
No specific recommendations at this time.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!aiResult && !aiMutation.isPending && (
|
{!aiResult && !isProcessing && !hasError && (
|
||||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||||
<IconSparkles size={28} />
|
<IconSparkles size={28} />
|
||||||
@@ -552,7 +750,7 @@ export function InvestmentPlanningPage() {
|
|||||||
AI-Powered Investment Analysis
|
AI-Powered Investment Analysis
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="sm" maw={500} mx="auto">
|
<Text c="dimmed" size="sm" maw={500} mx="auto">
|
||||||
Click "Get AI Recommendations" to analyze your accounts, cash flow,
|
Click "Get AI Recommendations" to analyze your accounts, cash flow,
|
||||||
budget, and capital projects against current market rates. The AI will
|
budget, and capital projects against current market rates. The AI will
|
||||||
suggest specific investment moves to maximize interest income while
|
suggest specific investment moves to maximize interest income while
|
||||||
maintaining adequate liquidity.
|
maintaining adequate liquidity.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } 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 Investment {
|
interface Investment {
|
||||||
id: string; name: string; institution: string; account_number_last4: string;
|
id: string; name: string; institution: string; account_number_last4: string;
|
||||||
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Investment | null>(null);
|
const [editing, setEditing] = useState<Investment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||||
queryKey: ['investments'],
|
queryKey: ['investments'],
|
||||||
@@ -76,6 +78,11 @@ export function InvestmentsPage() {
|
|||||||
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
|
const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
|
||||||
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
|
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
|
||||||
|
const projectedInterest = investments.reduce((s, i) => {
|
||||||
|
const value = parseFloat(i.current_value || i.principal || '0');
|
||||||
|
const rate = parseFloat(i.interest_rate || '0');
|
||||||
|
return s + (value * rate / 100);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const daysRemainingColor = (days: number | null) => {
|
const daysRemainingColor = (days: number | null) => {
|
||||||
if (days === null) return 'gray';
|
if (days === null) return 'gray';
|
||||||
@@ -90,12 +97,13 @@ export function InvestmentsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Investment Accounts</Title>
|
<Title order={2}>Investment Accounts</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
|
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>}
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
|
||||||
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Projected Annual Interest</Text><Text fw={700} size="xl" c="blue">{fmt(projectedInterest)}</Text></Card>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
@@ -133,7 +141,7 @@ export function InvestmentsPage() {
|
|||||||
) : '-'}
|
) : '-'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
||||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
|
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -15,15 +14,55 @@ 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 { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
@@ -35,13 +74,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 +116,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);
|
||||||
@@ -66,18 +126,20 @@ export function InvoicesPage() {
|
|||||||
<Title order={2}>Invoices</Title>
|
<Title order={2}>Invoices</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button>
|
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||||
</Group>
|
</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 +148,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>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} 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 { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -64,6 +65,7 @@ export function MonthlyActualsPage() {
|
|||||||
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -204,6 +206,7 @@ export function MonthlyActualsPage() {
|
|||||||
hideControls
|
hideControls
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
allowNegative
|
allowNegative
|
||||||
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -229,14 +232,16 @@ export function MonthlyActualsPage() {
|
|||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||||
<Button
|
{!isReadOnly && (
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
<Button
|
||||||
onClick={() => saveMutation.mutate()}
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
loading={saveMutation.isPending}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={lines.length === 0}
|
loading={saveMutation.isPending}
|
||||||
>
|
disabled={lines.length === 0}
|
||||||
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
>
|
||||||
</Button>
|
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} 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 { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface OrgMember {
|
interface OrgMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +52,7 @@ export function OrgMembersPage() {
|
|||||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||||
queryKey: ['org-members'],
|
queryKey: ['org-members'],
|
||||||
@@ -162,9 +163,11 @@ export function OrgMembersPage() {
|
|||||||
<Title order={2}>Organization Members</Title>
|
<Title order={2}>Organization Members</Title>
|
||||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
{!isReadOnly && (
|
||||||
Add Member
|
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||||
</Button>
|
Add Member
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
@@ -259,20 +262,22 @@ export function OrgMembersPage() {
|
|||||||
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
|
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
{!isReadOnly && (
|
||||||
<Tooltip label="Change role">
|
<Group gap={4}>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
|
<Tooltip label="Change role">
|
||||||
<IconEdit size={16} />
|
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
|
||||||
</ActionIcon>
|
<IconEdit size={16} />
|
||||||
</Tooltip>
|
|
||||||
{member.userId !== user?.id && (
|
|
||||||
<Tooltip label="Remove member">
|
|
||||||
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
{member.userId !== user?.id && (
|
||||||
</Group>
|
<Tooltip label="Remove member">
|
||||||
|
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
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';
|
||||||
|
|
||||||
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 { data: payments = [], isLoading } = useQuery<Payment[]>({
|
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||||
queryKey: ['payments'],
|
queryKey: ['payments'],
|
||||||
@@ -37,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);
|
||||||
@@ -51,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>;
|
||||||
|
|
||||||
@@ -74,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>
|
||||||
<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>
|
||||||
@@ -82,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>
|
||||||
@@ -94,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')} />
|
||||||
@@ -116,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen }
|
|||||||
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 { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -78,6 +79,7 @@ export function ProjectsPage() {
|
|||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
@@ -331,14 +333,16 @@ export function ProjectsPage() {
|
|||||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
{!isReadOnly && (<>
|
||||||
loading={importMutation.isPending}>
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
Import CSV
|
loading={importMutation.isPending}>
|
||||||
</Button>
|
Import CSV
|
||||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
+ Add Project
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
</Button>
|
+ Add Project
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -451,9 +455,11 @@ export function ProjectsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
{!isReadOnly && (
|
||||||
<IconEdit size={16} />
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
</ActionIcon>
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
IconCash, IconArrowUpRight, IconArrowDownRight,
|
IconCash, IconArrowUpRight, IconArrowDownRight,
|
||||||
IconWallet, IconReportMoney, IconSearch,
|
IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -58,6 +58,16 @@ export function CashFlowPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: aiRec } = useQuery<{ overall_assessment?: string; risk_notes?: string[] } | null>({
|
||||||
|
queryKey: ['saved-recommendation'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||||
|
return data;
|
||||||
|
} catch { return null; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
setQueryFrom(fromDate);
|
setQueryFrom(fromDate);
|
||||||
setQueryTo(toDate);
|
setQueryTo(toDate);
|
||||||
@@ -68,6 +78,10 @@ export function CashFlowPage() {
|
|||||||
|
|
||||||
const totalOperating = parseFloat(data?.total_operating || '0');
|
const totalOperating = parseFloat(data?.total_operating || '0');
|
||||||
const totalReserve = parseFloat(data?.total_reserve || '0');
|
const totalReserve = parseFloat(data?.total_reserve || '0');
|
||||||
|
const opInflows = (data?.operating_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
|
||||||
|
const opOutflows = Math.abs((data?.operating_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
|
||||||
|
const resInflows = (data?.reserve_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
|
||||||
|
const resOutflows = Math.abs((data?.reserve_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
|
||||||
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||||
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
||||||
@@ -132,10 +146,14 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
||||||
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Operating</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Activity</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
<Group justify="space-between" mb={4}>
|
||||||
{fmt(totalOperating)}
|
<Text size="xs" c="green">In: {fmt(opInflows)}</Text>
|
||||||
|
<Text size="xs" c="red">Out: {fmt(opOutflows)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="lg" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
||||||
|
{totalOperating >= 0 ? '+' : ''}{fmt(totalOperating)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
@@ -143,20 +161,31 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
||||||
<IconReportMoney size={14} />
|
<IconReportMoney size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Reserve</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Activity</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
<Group justify="space-between" mb={4}>
|
||||||
{fmt(totalReserve)}
|
<Text size="xs" c="green">In: {fmt(resInflows)}</Text>
|
||||||
|
<Text size="xs" c="red">Out: {fmt(resOutflows)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="lg" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
||||||
|
{totalReserve >= 0 ? '+' : ''}{fmt(totalReserve)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
<Group gap="xs" mb={4}>
|
<Group gap="xs" mb={4}>
|
||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
|
||||||
<IconCash size={14} />
|
<IconHeartRateMonitor size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Financial Health</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
|
{aiRec?.overall_assessment ? (
|
||||||
|
<Text fw={600} size="sm" lineClamp={3}>{aiRec.overall_assessment}</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text fw={700} size="xl" c="dimmed">TBD</Text>
|
||||||
|
<Text size="xs" c="dimmed">Pending AI Analysis</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Table, Group, Stack, Text, Card, Loader, Center,
|
||||||
|
Badge, SimpleGrid, Select, ThemeIcon, Alert,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface BudgetVsActualItem {
|
||||||
|
account_id: string;
|
||||||
|
account_number: string;
|
||||||
|
name: string;
|
||||||
|
account_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
quarter_budget: number;
|
||||||
|
quarter_actual: number;
|
||||||
|
quarter_variance: number;
|
||||||
|
ytd_budget: number;
|
||||||
|
ytd_actual: number;
|
||||||
|
ytd_variance: number;
|
||||||
|
variance_pct?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncomeStatement {
|
||||||
|
income: { name: string; amount: string; fund_type: string }[];
|
||||||
|
expenses: { name: string; amount: string; fund_type: string }[];
|
||||||
|
total_income: string;
|
||||||
|
total_expenses: string;
|
||||||
|
net_income: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuarterlyData {
|
||||||
|
year: number;
|
||||||
|
quarter: number;
|
||||||
|
quarter_label: string;
|
||||||
|
date_range: { from: string; to: string };
|
||||||
|
quarter_income_statement: IncomeStatement;
|
||||||
|
ytd_income_statement: IncomeStatement;
|
||||||
|
budget_vs_actual: BudgetVsActualItem[];
|
||||||
|
over_budget_items: BudgetVsActualItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuarterlyReportPage() {
|
||||||
|
const now = new Date();
|
||||||
|
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
|
const defaultQuarter = currentQuarter;
|
||||||
|
const defaultYear = now.getFullYear();
|
||||||
|
|
||||||
|
const [year, setYear] = useState(String(defaultYear));
|
||||||
|
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<QuarterlyData>({
|
||||||
|
queryKey: ['quarterly-report', year, quarter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/reports/quarterly?year=${year}&quarter=${quarter}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmt = (v: string | number) =>
|
||||||
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const y = now.getFullYear() - 2 + i;
|
||||||
|
return { value: String(y), label: String(y) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const quarterOptions = [
|
||||||
|
{ value: '1', label: 'Q1 (Jan-Mar)' },
|
||||||
|
{ value: '2', label: 'Q2 (Apr-Jun)' },
|
||||||
|
{ value: '3', label: 'Q3 (Jul-Sep)' },
|
||||||
|
{ value: '4', label: 'Q4 (Oct-Dec)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
const qIS = data?.quarter_income_statement;
|
||||||
|
const ytdIS = data?.ytd_income_statement;
|
||||||
|
const bva = data?.budget_vs_actual || [];
|
||||||
|
const overBudget = data?.over_budget_items || [];
|
||||||
|
|
||||||
|
const qRevenue = parseFloat(qIS?.total_income || '0');
|
||||||
|
const qExpenses = parseFloat(qIS?.total_expenses || '0');
|
||||||
|
const qNet = parseFloat(qIS?.net_income || '0');
|
||||||
|
const ytdNet = parseFloat(ytdIS?.net_income || '0');
|
||||||
|
|
||||||
|
const incomeItems = bva.filter((b) => b.account_type === 'income');
|
||||||
|
const expenseItems = bva.filter((b) => b.account_type === 'expense');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={2}>Quarterly Financial Report</Title>
|
||||||
|
<Group>
|
||||||
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
|
<Select data={quarterOptions} value={quarter} onChange={(v) => v && setQuarter(v)} w={160} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{data.quarter_label} · {new Date(data.date_range.from + 'T00:00:00').toLocaleDateString()} – {new Date(data.date_range.to + 'T00:00:00').toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="green" size="sm"><IconTrendingUp size={14} /></ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Revenue</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c="green">{fmt(qRevenue)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="red" size="sm"><IconTrendingDown size={14} /></ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Expenses</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c="red">{fmt(qExpenses)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color={qNet >= 0 ? 'green' : 'red'} size="sm">
|
||||||
|
<IconChartBar size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Net</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c={qNet >= 0 ? 'green' : 'red'}>{fmt(qNet)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color={ytdNet >= 0 ? 'green' : 'red'} size="sm">
|
||||||
|
<IconChartBar size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>YTD Net</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c={ytdNet >= 0 ? 'green' : 'red'}>{fmt(ytdNet)}</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Over-Budget Alert */}
|
||||||
|
{overBudget.length > 0 && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Group mb="md">
|
||||||
|
<IconAlertTriangle size={20} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Title order={4}>Over-Budget Items ({overBudget.length})</Title>
|
||||||
|
</Group>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Account</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Budget</Table.Th>
|
||||||
|
<Table.Th ta="right">Actual</Table.Th>
|
||||||
|
<Table.Th ta="right">Over By</Table.Th>
|
||||||
|
<Table.Th ta="right">% Over</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{overBudget.map((item) => (
|
||||||
|
<Table.Tr key={item.account_id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>{item.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{item.account_number}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||||
|
{item.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_actual)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_variance)}</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Badge color="red" variant="light" size="sm">+{item.variance_pct}%</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Budget vs Actuals */}
|
||||||
|
<Card withBorder>
|
||||||
|
<Title order={4} mb="md">Budget vs Actuals</Title>
|
||||||
|
{bva.length === 0 ? (
|
||||||
|
<Alert variant="light" color="blue">No budget or actual data for this quarter.</Alert>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover style={{ minWidth: 900 }}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Account</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Q Budget</Table.Th>
|
||||||
|
<Table.Th ta="right">Q Actual</Table.Th>
|
||||||
|
<Table.Th ta="right">Q Variance</Table.Th>
|
||||||
|
<Table.Th ta="right">YTD Budget</Table.Th>
|
||||||
|
<Table.Th ta="right">YTD Actual</Table.Th>
|
||||||
|
<Table.Th ta="right">YTD Variance</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{incomeItems.length > 0 && (
|
||||||
|
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||||
|
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{incomeItems.map((item) => (
|
||||||
|
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||||
|
))}
|
||||||
|
{incomeItems.length > 0 && (
|
||||||
|
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||||
|
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{expenseItems.length > 0 && (
|
||||||
|
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||||
|
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{expenseItems.map((item) => (
|
||||||
|
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||||
|
))}
|
||||||
|
{expenseItems.length > 0 && (
|
||||||
|
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||||
|
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BVARow({ item, isExpense }: { item: BudgetVsActualItem; isExpense: boolean }) {
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
// For expenses, over budget (positive variance) is bad (red)
|
||||||
|
// For income, under budget (negative variance) is bad (red)
|
||||||
|
const qVarianceColor = isExpense
|
||||||
|
? (item.quarter_variance > 0 ? 'red' : 'green')
|
||||||
|
: (item.quarter_variance < 0 ? 'red' : 'green');
|
||||||
|
const ytdVarianceColor = isExpense
|
||||||
|
? (item.ytd_variance > 0 ? 'red' : 'green')
|
||||||
|
: (item.ytd_variance < 0 ? 'red' : 'green');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{item.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{item.account_number}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||||
|
{item.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_actual)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c={item.quarter_variance !== 0 ? qVarianceColor : undefined}>
|
||||||
|
{fmt(item.quarter_variance)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_budget)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_actual)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c={item.ytd_variance !== 0 ? ytdVarianceColor : undefined}>
|
||||||
|
{fmt(item.ytd_variance)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
|
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +52,8 @@ export function SankeyPage() {
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
|
const [source, setSource] = useState('actuals');
|
||||||
|
const [fundFilter, setFundFilter] = useState('all');
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -59,9 +61,12 @@ export function SankeyPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
||||||
queryKey: ['sankey', year],
|
queryKey: ['sankey', year, source, fundFilter],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/reports/cash-flow-sankey?year=${year}`);
|
const params = new URLSearchParams({ year });
|
||||||
|
if (source !== 'actuals') params.set('source', source);
|
||||||
|
if (fundFilter !== 'all') params.set('fundType', fundFilter);
|
||||||
|
const { data } = await api.get(`/reports/cash-flow-sankey?${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -191,6 +196,31 @@ export function SankeyPage() {
|
|||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Text size="sm" fw={500}>Data source:</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={source}
|
||||||
|
onChange={setSource}
|
||||||
|
data={[
|
||||||
|
{ label: 'Actuals', value: 'actuals' },
|
||||||
|
{ label: 'Budget', value: 'budget' },
|
||||||
|
{ label: 'Forecast', value: 'forecast' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={500} ml="md">Fund:</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={fundFilter}
|
||||||
|
onChange={setFundFilter}
|
||||||
|
data={[
|
||||||
|
{ label: 'All Funds', value: 'all' },
|
||||||
|
{ label: 'Operating', value: 'operating' },
|
||||||
|
{ label: 'Reserve', value: 'reserve' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } 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 ReserveComponent {
|
interface ReserveComponent {
|
||||||
id: string; name: string; category: string; description: string;
|
id: string; name: string; category: string; description: string;
|
||||||
@@ -26,6 +27,7 @@ export function ReservesPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||||
queryKey: ['reserve-components'],
|
queryKey: ['reserve-components'],
|
||||||
@@ -89,7 +91,7 @@ export function ReservesPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Reserve Components</Title>
|
<Title order={2}>Reserve Components</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>
|
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>}
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
@@ -139,7 +141,7 @@ export function ReservesPage() {
|
|||||||
{c.condition_rating}/10
|
{c.condition_rating}/10
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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">0.2.0 MVP_P2</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>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
|||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
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 JournalEntryLine {
|
interface JournalEntryLine {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -48,6 +49,7 @@ export function TransactionsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||||
queryKey: ['journal-entries'],
|
queryKey: ['journal-entries'],
|
||||||
@@ -164,9 +166,11 @@ export function TransactionsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Journal Entries</Title>
|
<Title order={2}>Journal Entries</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={open}>
|
{!isReadOnly && (
|
||||||
New Entry
|
<Button leftSection={<IconPlus size={16} />} onClick={open}>
|
||||||
</Button>
|
New Entry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
@@ -216,14 +220,14 @@ export function TransactionsPage() {
|
|||||||
<IconEye size={16} />
|
<IconEye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!e.is_posted && !e.is_void && (
|
{!isReadOnly && !e.is_posted && !e.is_void && (
|
||||||
<Tooltip label="Post">
|
<Tooltip label="Post">
|
||||||
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
|
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
|
||||||
<IconCheck size={16} />
|
<IconCheck size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{e.is_posted && !e.is_void && (
|
{!isReadOnly && e.is_posted && !e.is_void && (
|
||||||
<Tooltip label="Void">
|
<Tooltip label="Void">
|
||||||
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
|
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
|
||||||
<IconX size={16} />
|
<IconX size={16} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
|
|||||||
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 { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +43,7 @@ export function UnitsPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||||
queryKey: ['units'],
|
queryKey: ['units'],
|
||||||
@@ -163,18 +165,20 @@ export function UnitsPage() {
|
|||||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
{!isReadOnly && (<>
|
||||||
loading={importMutation.isPending}>
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
Import CSV
|
loading={importMutation.isPending}>
|
||||||
</Button>
|
Import CSV
|
||||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
</Button>
|
||||||
{hasGroups ? (
|
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
{hasGroups ? (
|
||||||
) : (
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
||||||
<Tooltip label="Create an assessment group first">
|
) : (
|
||||||
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
<Tooltip label="Create an assessment group first">
|
||||||
</Tooltip>
|
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||||
)}
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -224,16 +228,18 @@ export function UnitsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
{!isReadOnly && (
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
|
<Group gap={4}>
|
||||||
<IconEdit size={16} />
|
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
|
||||||
</ActionIcon>
|
<IconEdit size={16} />
|
||||||
<Tooltip label="Delete unit">
|
|
||||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
<Tooltip label="Delete unit">
|
||||||
</Group>
|
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
35
frontend/src/pages/vendors/VendorsPage.tsx
vendored
35
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -3,18 +3,21 @@ import {
|
|||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||||
} 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 { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } 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 { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||||
address_line1: string; city: string; state: string; zip_code: string;
|
address_line1: string; city: string; state: string; zip_code: string;
|
||||||
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
||||||
|
last_negotiated: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorsPage() {
|
export function VendorsPage() {
|
||||||
@@ -23,6 +26,7 @@ export function VendorsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||||
queryKey: ['vendors'],
|
queryKey: ['vendors'],
|
||||||
@@ -34,12 +38,19 @@ export function VendorsPage() {
|
|||||||
name: '', contact_name: '', email: '', phone: '',
|
name: '', contact_name: '', email: '', phone: '',
|
||||||
address_line1: '', city: '', state: '', zip_code: '',
|
address_line1: '', city: '', state: '', zip_code: '',
|
||||||
tax_id: '', is_1099_eligible: false,
|
tax_id: '', is_1099_eligible: false,
|
||||||
|
last_negotiated: null as Date | null,
|
||||||
},
|
},
|
||||||
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: any) => editing ? api.put(`/vendors/${editing.id}`, values) : api.post('/vendors', values),
|
mutationFn: (values: any) => {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
last_negotiated: values.last_negotiated ? values.last_negotiated.toISOString().split('T')[0] : null,
|
||||||
|
};
|
||||||
|
return editing ? api.put(`/vendors/${editing.id}`, payload) : api.post('/vendors', payload);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||||
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
|
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
|
||||||
@@ -91,6 +102,7 @@ export function VendorsPage() {
|
|||||||
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
|
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
|
||||||
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
||||||
is_1099_eligible: v.is_1099_eligible,
|
is_1099_eligible: v.is_1099_eligible,
|
||||||
|
last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -107,12 +119,14 @@ export function VendorsPage() {
|
|||||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
{!isReadOnly && (<>
|
||||||
loading={importMutation.isPending}>
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
Import CSV
|
loading={importMutation.isPending}>
|
||||||
</Button>
|
Import CSV
|
||||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
||||||
@@ -122,6 +136,7 @@ export function VendorsPage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
|
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
|
||||||
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
|
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
|
||||||
|
<Table.Th>Last Negotiated</Table.Th>
|
||||||
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
|
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -133,11 +148,12 @@ export function VendorsPage() {
|
|||||||
<Table.Td>{v.email}</Table.Td>
|
<Table.Td>{v.email}</Table.Td>
|
||||||
<Table.Td>{v.phone}</Table.Td>
|
<Table.Td>{v.phone}</Table.Td>
|
||||||
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
|
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
|
||||||
|
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
|
||||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||||
@@ -157,6 +173,7 @@ export function VendorsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
||||||
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
|
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
|
||||||
|
<DateInput label="Last Negotiated" clearable placeholder="Select date" {...form.getInputProps('last_negotiated')} />
|
||||||
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Organization {
|
|||||||
role: string;
|
role: string;
|
||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -16,6 +17,7 @@ interface User {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
isSuperadmin?: boolean;
|
isSuperadmin?: boolean;
|
||||||
isPlatformOwner?: boolean;
|
isPlatformOwner?: boolean;
|
||||||
|
hasSeenIntro?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImpersonationOriginal {
|
interface ImpersonationOriginal {
|
||||||
@@ -33,11 +35,16 @@ interface AuthState {
|
|||||||
impersonationOriginal: ImpersonationOriginal | null;
|
impersonationOriginal: ImpersonationOriginal | null;
|
||||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||||
|
setUserIntroSeen: () => void;
|
||||||
|
setOrgSettings: (settings: Record<string, any>) => void;
|
||||||
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
stopImpersonation: () => void;
|
stopImpersonation: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hook to check if the current user has read-only (viewer) access */
|
||||||
|
export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer');
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -59,6 +66,16 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
currentOrg: org,
|
currentOrg: org,
|
||||||
token: token || state.token,
|
token: token || state.token,
|
||||||
})),
|
})),
|
||||||
|
setUserIntroSeen: () =>
|
||||||
|
set((state) => ({
|
||||||
|
user: state.user ? { ...state.user, hasSeenIntro: true } : null,
|
||||||
|
})),
|
||||||
|
setOrgSettings: (settings) =>
|
||||||
|
set((state) => ({
|
||||||
|
currentOrg: state.currentOrg
|
||||||
|
? { ...state.currentOrg, settings: { ...(state.currentOrg.settings || {}), ...settings } }
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
startImpersonation: (token, user, organizations) => {
|
startImpersonation: (token, user, organizations) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
set({
|
set({
|
||||||
@@ -97,7 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 4,
|
version: 5,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
26
frontend/src/stores/preferencesStore.ts
Normal file
26
frontend/src/stores/preferencesStore.ts
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['app.hoaledgeriq.com'],
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
18
nginx/certbot-init.conf
Normal file
18
nginx/certbot-init.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Temporary nginx config — used ONLY during the initial certbot certificate
|
||||||
|
# request. Once the cert is obtained, switch to ssl.conf and restart nginx.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Certbot ACME challenge
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return 503 for everything else so it's obvious this is not the real app
|
||||||
|
location / {
|
||||||
|
return 503 "SSL certificate is being provisioned. Try again in a minute.\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,21 +23,8 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
# AI endpoints now return immediately (async processing in background)
|
||||||
location /api/investment-planning/recommendations {
|
# No special timeout needed — kept for documentation purposes
|
||||||
proxy_pass http://backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
proxy_read_timeout 180s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
proxy_send_timeout 30s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Everything else -> Vite dev server (frontend)
|
# Everything else -> Vite dev server (frontend)
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
87
nginx/host-production.conf
Normal file
87
nginx/host-production.conf
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# HOA LedgerIQ — Host-level nginx config (production)
|
||||||
|
#
|
||||||
|
# Copy this file to /etc/nginx/sites-available/app.yourdomain.com
|
||||||
|
# and symlink to /etc/nginx/sites-enabled/:
|
||||||
|
#
|
||||||
|
# sudo cp nginx/host-production.conf /etc/nginx/sites-available/app.yourdomain.com
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/app.yourdomain.com /etc/nginx/sites-enabled/
|
||||||
|
# sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
#
|
||||||
|
# Then obtain an SSL certificate:
|
||||||
|
# sudo certbot --nginx -d app.yourdomain.com
|
||||||
|
#
|
||||||
|
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
|
||||||
|
|
||||||
|
# --- Rate limiting ---
|
||||||
|
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs)
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
|
|
||||||
|
# --- HTTP → HTTPS redirect ---
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name app.yourdomain.com;
|
||||||
|
|
||||||
|
# Let certbot answer ACME challenges
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else → HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main HTTPS server ---
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name app.yourdomain.com;
|
||||||
|
|
||||||
|
# SSL certificates (managed by certbot)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/app.yourdomain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/app.yourdomain.com/privkey.pem;
|
||||||
|
|
||||||
|
# Modern TLS settings
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# --- Proxy defaults ---
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Buffer settings — prevent 502s when backend is slow to respond
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 16k;
|
||||||
|
proxy_buffers 8 16k;
|
||||||
|
proxy_busy_buffers_size 32k;
|
||||||
|
|
||||||
|
# --- API routes → NestJS backend (port 3000) ---
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api_limit burst=30 nodelay;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_send_timeout 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI endpoints now return immediately (async processing in background)
|
||||||
|
# No special timeout overrides needed
|
||||||
|
|
||||||
|
# --- Frontend → React SPA served by nginx (port 3001) ---
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
nginx/production.conf
Normal file
53
nginx/production.conf
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
upstream backend {
|
||||||
|
server backend:3000;
|
||||||
|
keepalive 32; # reuse connections to backend
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:3001;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared proxy settings
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection ""; # enable keepalive to upstreams
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Buffer settings — prevent 502s when backend is slow to respond
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 16k;
|
||||||
|
proxy_buffers 8 16k;
|
||||||
|
proxy_busy_buffers_size 32k;
|
||||||
|
|
||||||
|
# Rate limit zone (10 req/s per IP for API)
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
|
|
||||||
|
# HTTP server — SSL termination is handled by the host reverse proxy
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# --- API routes → backend ---
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api_limit burst=30 nodelay;
|
||||||
|
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_send_timeout 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI endpoints now return immediately (async processing in background)
|
||||||
|
# No special timeout overrides needed
|
||||||
|
|
||||||
|
# --- Static frontend → built React assets ---
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
nginx/ssl.conf
Normal file
77
nginx/ssl.conf
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
upstream backend {
|
||||||
|
server backend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:3001;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect all HTTP to HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Let certbot answer ACME challenges over HTTP
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else -> HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
# Replace with your actual hostname:
|
||||||
|
server_name staging.example.com;
|
||||||
|
|
||||||
|
# --- TLS certificates (managed by certbot) ---
|
||||||
|
ssl_certificate /etc/letsencrypt/live/staging.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/staging.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# --- Modern TLS settings ---
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# --- Security headers ---
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
|
||||||
|
# --- Proxy routes (same as default.conf) ---
|
||||||
|
|
||||||
|
# API requests -> NestJS backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI endpoints now return immediately (async processing in background)
|
||||||
|
# No special timeout overrides needed
|
||||||
|
|
||||||
|
# Everything else -> Vite dev server (frontend)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
296
scripts/db-backup.sh
Executable file
296
scripts/db-backup.sh
Executable file
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# db-backup.sh — Backup & restore the HOA LedgerIQ PostgreSQL database
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/db-backup.sh backup [--dir /path/to/backups] [--keep N]
|
||||||
|
# ./scripts/db-backup.sh restore <file.sql.gz | file.dump.gz>
|
||||||
|
# ./scripts/db-backup.sh list [--dir /path/to/backups]
|
||||||
|
#
|
||||||
|
# Backup produces a gzipped custom-format dump with a timestamped filename:
|
||||||
|
# backups/hoafinance_2026-03-02_140530.dump.gz
|
||||||
|
#
|
||||||
|
# Cron example (daily at 2 AM, keep 30 days):
|
||||||
|
# 0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- Defaults ----
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BACKUP_DIR="$PROJECT_DIR/backups"
|
||||||
|
KEEP_DAYS=0 # 0 = keep forever
|
||||||
|
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_postgres_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
|
||||||
|
}
|
||||||
|
|
||||||
|
format_size() {
|
||||||
|
local bytes=$1
|
||||||
|
if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)"
|
||||||
|
elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)"
|
||||||
|
elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)"
|
||||||
|
else printf "%d B" "$bytes"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- BACKUP ----
|
||||||
|
|
||||||
|
do_backup() {
|
||||||
|
ensure_postgres_running
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
local timestamp
|
||||||
|
timestamp="$(date +%Y-%m-%d_%H%M%S)"
|
||||||
|
local filename="${DB_NAME}_${timestamp}.dump.gz"
|
||||||
|
local filepath="$BACKUP_DIR/$filename"
|
||||||
|
|
||||||
|
info "Starting backup of database '${DB_NAME}' ..."
|
||||||
|
|
||||||
|
# pg_dump inside the container, stream through gzip on the host
|
||||||
|
$COMPOSE_CMD exec -T postgres pg_dump \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
--format=custom \
|
||||||
|
| gzip -9 > "$filepath"
|
||||||
|
|
||||||
|
local size
|
||||||
|
size=$(wc -c < "$filepath" | tr -d ' ')
|
||||||
|
|
||||||
|
if [ "$size" -lt 100 ]; then
|
||||||
|
rm -f "$filepath"
|
||||||
|
die "Backup file is suspiciously small — something went wrong. Check docker compose logs postgres."
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Backup complete: ${filepath} ($(format_size "$size"))"
|
||||||
|
|
||||||
|
# ---- Prune old backups ----
|
||||||
|
if [ "$KEEP_DAYS" -gt 0 ]; then
|
||||||
|
local pruned=0
|
||||||
|
while IFS= read -r old_file; do
|
||||||
|
rm -f "$old_file"
|
||||||
|
((pruned++))
|
||||||
|
done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*.dump.gz" -mtime +"$KEEP_DAYS" -type f 2>/dev/null)
|
||||||
|
if [ "$pruned" -gt 0 ]; then
|
||||||
|
info "Pruned $pruned backup(s) older than $KEEP_DAYS days"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- RESTORE ----
|
||||||
|
|
||||||
|
do_restore() {
|
||||||
|
local file="$1"
|
||||||
|
|
||||||
|
# Resolve relative path
|
||||||
|
if [[ "$file" != /* ]]; then
|
||||||
|
file="$(pwd)/$file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$file" ] || die "File not found: $file"
|
||||||
|
|
||||||
|
ensure_postgres_running
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
|
||||||
|
warn "with the contents of: $(basename "$file")"
|
||||||
|
echo ""
|
||||||
|
read -rp "Type 'yes' to continue: " confirm
|
||||||
|
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Step 1/4 — Terminating active connections ..."
|
||||||
|
$COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d postgres -c "
|
||||||
|
SELECT pg_terminate_backend(pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
|
||||||
|
" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
info "Step 2/4 — Dropping and recreating database ..."
|
||||||
|
$COMPOSE_CMD exec -T postgres dropdb -U "$DB_USER" --if-exists "$DB_NAME"
|
||||||
|
$COMPOSE_CMD exec -T postgres createdb -U "$DB_USER" "$DB_NAME"
|
||||||
|
|
||||||
|
info "Step 3/4 — Restoring from $(basename "$file") ..."
|
||||||
|
|
||||||
|
if [[ "$file" == *.dump.gz ]]; then
|
||||||
|
# Custom-format dump, gzipped → decompress and pipe to pg_restore
|
||||||
|
gunzip -c "$file" | $COMPOSE_CMD exec -T postgres pg_restore \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
--exit-on-error 2>&1 | tail -5 || true
|
||||||
|
|
||||||
|
elif [[ "$file" == *.sql.gz ]]; then
|
||||||
|
# Plain SQL dump, gzipped → decompress and pipe to psql
|
||||||
|
gunzip -c "$file" | $COMPOSE_CMD exec -T postgres psql \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--quiet 2>&1 | tail -5 || true
|
||||||
|
|
||||||
|
elif [[ "$file" == *.dump ]]; then
|
||||||
|
# Custom-format dump, not compressed
|
||||||
|
$COMPOSE_CMD exec -T postgres pg_restore \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
--exit-on-error < "$file" 2>&1 | tail -5 || true
|
||||||
|
|
||||||
|
elif [[ "$file" == *.sql ]]; then
|
||||||
|
# Plain SQL dump, not compressed
|
||||||
|
$COMPOSE_CMD exec -T postgres psql \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--quiet < "$file" 2>&1 | tail -5 || true
|
||||||
|
|
||||||
|
else
|
||||||
|
die "Unsupported file format. Expected .dump.gz, .sql.gz, .dump, or .sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Step 4/4 — Restarting backend ..."
|
||||||
|
$COMPOSE_CMD restart backend > /dev/null 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Restore complete. Backend restarted."
|
||||||
|
|
||||||
|
# Quick sanity check
|
||||||
|
local tenant_count
|
||||||
|
tenant_count=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
|
||||||
|
-t -c "SELECT count(*) FROM shared.organizations WHERE status = 'active';" 2>/dev/null | tr -d ' ')
|
||||||
|
info "Active tenants found: ${tenant_count:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- LIST ----
|
||||||
|
|
||||||
|
do_list() {
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
local count=0
|
||||||
|
echo ""
|
||||||
|
printf " %-42s %10s %s\n" "FILENAME" "SIZE" "DATE"
|
||||||
|
printf " %-42s %10s %s\n" "--------" "----" "----"
|
||||||
|
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
local size
|
||||||
|
size=$(wc -c < "$f" | tr -d ' ')
|
||||||
|
local mod_date
|
||||||
|
mod_date=$(date -r "$f" "+%Y-%m-%d %H:%M" 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
|
||||||
|
printf " %-42s %10s %s\n" "$(basename "$f")" "$(format_size "$size")" "$mod_date"
|
||||||
|
((count++))
|
||||||
|
done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*" -type f 2>/dev/null | sort)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
info "No backups found in $BACKUP_DIR"
|
||||||
|
else
|
||||||
|
info "$count backup(s) in $BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- CLI ----
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
HOA LedgerIQ Database Backup & Restore
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
$(basename "$0") backup [--dir DIR] [--keep DAYS] Create a timestamped gzipped backup
|
||||||
|
$(basename "$0") restore FILE Restore from a backup file
|
||||||
|
$(basename "$0") list [--dir DIR] List available backups
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dir DIR Backup directory (default: ./backups)
|
||||||
|
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
|
||||||
|
|
||||||
|
Supported restore formats:
|
||||||
|
.dump.gz Custom-format pg_dump, gzipped (default backup format)
|
||||||
|
.sql.gz Plain SQL dump, gzipped
|
||||||
|
.dump Custom-format pg_dump, uncompressed
|
||||||
|
.sql Plain SQL dump, uncompressed
|
||||||
|
|
||||||
|
Cron example (daily at 2 AM, retain 30 days):
|
||||||
|
0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse command
|
||||||
|
COMMAND="${1:-}"
|
||||||
|
shift 2>/dev/null || true
|
||||||
|
|
||||||
|
[ -z "$COMMAND" ] && usage
|
||||||
|
|
||||||
|
# Parse flags
|
||||||
|
RESTORE_FILE=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
||||||
|
--keep) KEEP_DAYS="$2"; shift 2 ;;
|
||||||
|
--help) usage ;;
|
||||||
|
*)
|
||||||
|
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then
|
||||||
|
RESTORE_FILE="$1"; shift
|
||||||
|
else
|
||||||
|
die "Unknown argument: $1"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Load .env if present (for POSTGRES_USER / POSTGRES_DB)
|
||||||
|
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
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
|
backup)
|
||||||
|
do_backup
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
[ -z "$RESTORE_FILE" ] && die "Usage: $(basename "$0") restore <file>"
|
||||||
|
do_restore "$RESTORE_FILE"
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
do_list
|
||||||
|
;;
|
||||||
|
-h|--help|help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown command: $COMMAND (try: backup, restore, list)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env tsx
|
#!/usr/bin/env tsx
|
||||||
/**
|
/**
|
||||||
* CD Rate Fetcher Script
|
* Market Rate Fetcher Script
|
||||||
*
|
*
|
||||||
* Scrapes the top CD rates from Bankrate.com and stores them in the
|
* Scrapes the top CD, Money Market, and High Yield Savings rates from
|
||||||
* shared.cd_rates table in PostgreSQL. Designed to run standalone via cron.
|
* Bankrate.com and stores them in the shared.cd_rates table in PostgreSQL.
|
||||||
|
* Designed to run standalone via cron (once per day).
|
||||||
*
|
*
|
||||||
* Bankrate renders rate data dynamically via JavaScript, so this script
|
* Historical data is preserved — each fetch adds new rows with the current
|
||||||
* uses Puppeteer (headless Chrome) to fully render the page before scraping.
|
* timestamp. The application queries only the latest batch per rate type.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* cd scripts
|
* cd scripts
|
||||||
@@ -20,20 +21,39 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import puppeteer, { type Browser } from 'puppeteer';
|
import puppeteer, { type Browser, type Page } from 'puppeteer';
|
||||||
|
|
||||||
// Load .env from project root
|
// Load .env from project root
|
||||||
dotenv.config({ path: resolve(__dirname, '..', '.env') });
|
dotenv.config({ path: resolve(__dirname, '..', '.env') });
|
||||||
|
|
||||||
const BANKRATE_URL = 'https://www.bankrate.com/banking/cds/cd-rates/';
|
|
||||||
const MAX_RATES = 25;
|
const MAX_RATES = 25;
|
||||||
|
|
||||||
interface CdRate {
|
// Rate source configurations
|
||||||
|
const RATE_SOURCES = [
|
||||||
|
{
|
||||||
|
type: 'cd',
|
||||||
|
label: 'CD Rates',
|
||||||
|
url: 'https://www.bankrate.com/banking/cds/cd-rates/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'high_yield_savings',
|
||||||
|
label: 'High Yield Savings',
|
||||||
|
url: 'https://www.bankrate.com/banking/savings/best-high-yield-interests-savings-accounts/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'money_market',
|
||||||
|
label: 'Money Market',
|
||||||
|
url: 'https://www.bankrate.com/banking/money-market/rates/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MarketRate {
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
apy: number;
|
apy: number;
|
||||||
min_deposit: number | null;
|
min_deposit: number | null;
|
||||||
term: string;
|
term: string;
|
||||||
term_months: number | null;
|
term_months: number | null;
|
||||||
|
rate_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,12 +61,10 @@ interface CdRate {
|
|||||||
*/
|
*/
|
||||||
function parseTermMonths(term: string): number | null {
|
function parseTermMonths(term: string): number | null {
|
||||||
const lower = term.toLowerCase().trim();
|
const lower = term.toLowerCase().trim();
|
||||||
const monthMatch = lower.match(/(\d+)\s*month/);
|
const monthMatch = lower.match(/(\d+)\s*mo(?:nth)?/);
|
||||||
if (monthMatch) return parseInt(monthMatch[1], 10);
|
if (monthMatch) return parseInt(monthMatch[1], 10);
|
||||||
const yearMatch = lower.match(/(\d+)\s*year/);
|
// Handle fractional years like "1.5 years" or "1.5 yr"
|
||||||
if (yearMatch) return parseInt(yearMatch[1], 10) * 12;
|
const fracYearMatch = lower.match(/([\d.]+)\s*y(?:ear|r)/);
|
||||||
// Handle fractional years like "1.5 years"
|
|
||||||
const fracYearMatch = lower.match(/([\d.]+)\s*year/);
|
|
||||||
if (fracYearMatch) return Math.round(parseFloat(fracYearMatch[1]) * 12);
|
if (fracYearMatch) return Math.round(parseFloat(fracYearMatch[1]) * 12);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,54 +82,71 @@ function parseMinDeposit(raw: string): number | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an APY string like "4.50%", "4.50% APY" into a number.
|
* Parse an APY string like "4.50%", "4.50% APY" into a number.
|
||||||
|
* Handles edge cases like ".4.50%" (leading period from adjacent text).
|
||||||
*/
|
*/
|
||||||
function parseApy(raw: string): number {
|
function parseApy(raw: string): number {
|
||||||
const cleaned = raw.replace(/[^0-9.]/g, '');
|
// Extract the first valid decimal number (digit-leading) from the string
|
||||||
return parseFloat(cleaned) || 0;
|
const match = raw.match(/(\d+\.?\d*)/);
|
||||||
|
if (!match) return 0;
|
||||||
|
const val = parseFloat(match[1]);
|
||||||
|
return isNaN(val) ? 0 : val;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch headless Chrome, navigate to Bankrate, and scrape CD rate data.
|
* Pause execution for a given number of milliseconds.
|
||||||
*/
|
*/
|
||||||
async function fetchRates(): Promise<CdRate[]> {
|
function sleep(ms: number): Promise<void> {
|
||||||
let browser: Browser | null = null;
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a Bankrate URL and scrape rate data from individual bank offer cards.
|
||||||
|
*
|
||||||
|
* Bankrate uses a card-based layout with two sections:
|
||||||
|
* - .wrt-RateSections-sponsoredoffers (sponsored bank offers)
|
||||||
|
* - .wrt-RateSections-additionaloffers (additional bank offers)
|
||||||
|
*
|
||||||
|
* Each card (.rounded-md) contains:
|
||||||
|
* - Bank name in img[alt] (the logo)
|
||||||
|
* - APY after "APY as of" text
|
||||||
|
* - Min. deposit (CDs) or Min. balance for APY (savings/MM)
|
||||||
|
* - Term (CDs only): e.g. "1yr", "14mo"
|
||||||
|
*
|
||||||
|
* The page also has a summary table (.wealth-product-rate-list) with "best rates"
|
||||||
|
* per term but NO bank names — we explicitly skip this table.
|
||||||
|
*/
|
||||||
|
async function fetchRatesFromPage(
|
||||||
|
browser: Browser,
|
||||||
|
sourceUrl: string,
|
||||||
|
rateType: string,
|
||||||
|
label: string,
|
||||||
|
): Promise<MarketRate[]> {
|
||||||
|
const page: Page = await browser.newPage();
|
||||||
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Launching headless browser...');
|
console.log(`\n--- Fetching ${label} ---`);
|
||||||
browser = await puppeteer.launch({
|
console.log(`Navigating to ${sourceUrl}...`);
|
||||||
headless: true,
|
await page.goto(sourceUrl, {
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setUserAgent(
|
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Navigating to ${BANKRATE_URL}...`);
|
|
||||||
await page.goto(BANKRATE_URL, {
|
|
||||||
waitUntil: 'networkidle2',
|
waitUntil: 'networkidle2',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for rate content to render
|
// Wait for rate card sections to render
|
||||||
// Bankrate uses various table/card patterns; we'll try multiple selectors
|
console.log('Waiting for rate cards to render...');
|
||||||
console.log('Waiting for rate data to render...');
|
|
||||||
await page.waitForSelector(
|
await page.waitForSelector(
|
||||||
'table, [data-testid*="rate"], .brc-table, [class*="ComparisonTable"], [class*="rate-table"]',
|
'.wrt-RateSections-sponsoredoffers .rounded-md, .wrt-RateSections-additionaloffers .rounded-md',
|
||||||
{ timeout: 30000 },
|
{ timeout: 30000 },
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
console.log('Primary selectors not found, proceeding with page scan...');
|
console.log('Bankrate card selectors not found, will try fallback...');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extra wait for dynamic content
|
// Extra wait for dynamic content
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
await sleep(3000);
|
||||||
|
|
||||||
// Scroll down to load all content (rate tables may be below the fold)
|
// Scroll down to load all content
|
||||||
console.log('Scrolling to load all content...');
|
console.log('Scrolling to load all content...');
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
@@ -120,10 +155,10 @@ async function fetchRates(): Promise<CdRate[]> {
|
|||||||
}
|
}
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await sleep(2000);
|
||||||
|
|
||||||
// Extract rate data from the page using multiple strategies
|
// Extract rate data from individual bank offer cards
|
||||||
const rates = await page.evaluate((maxRates: number) => {
|
const rawRates = await page.evaluate((maxRates: number) => {
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
apy_raw: string;
|
apy_raw: string;
|
||||||
@@ -131,125 +166,114 @@ async function fetchRates(): Promise<CdRate[]> {
|
|||||||
term_raw: string;
|
term_raw: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// Strategy 1: Look for detailed bank comparison tables with named banks
|
// Primary strategy: extract from Bankrate offer cards
|
||||||
// These typically have 4+ columns: Bank, APY, Min Deposit, Term
|
// Both sponsored and additional offer sections use the same card structure
|
||||||
const tables = document.querySelectorAll('table');
|
const cards = [
|
||||||
for (const table of tables) {
|
...document.querySelectorAll('.wrt-RateSections-sponsoredoffers > .rounded-md'),
|
||||||
const rows = table.querySelectorAll('tbody tr');
|
...document.querySelectorAll('.wrt-RateSections-additionaloffers > .rounded-md'),
|
||||||
if (rows.length < 3) continue; // Skip small tables
|
];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const card of cards) {
|
||||||
const cells = row.querySelectorAll('td, th');
|
const text = card.textContent || '';
|
||||||
if (cells.length < 3) continue;
|
|
||||||
|
|
||||||
const texts = Array.from(cells).map((c) => c.textContent?.trim() || '');
|
// Bank name: from the logo img alt attribute (most reliable)
|
||||||
const apyCell = texts.find((t) => /\d+\.\d+\s*%/.test(t));
|
const img = card.querySelector('img[alt]');
|
||||||
if (!apyCell) continue;
|
let bankName = img ? (img as HTMLImageElement).alt.trim() : '';
|
||||||
|
|
||||||
// Bank name: look for a cell with a real name (not just number/percent/dollar)
|
// Fallback: extract from text before "Add to compare"
|
||||||
const bankCell = texts.find(
|
if (!bankName) {
|
||||||
(t) =>
|
const addIdx = text.indexOf('Add to compare');
|
||||||
t.length > 3 &&
|
if (addIdx > 0) {
|
||||||
!/^\d/.test(t) &&
|
bankName = text.substring(0, addIdx)
|
||||||
!t.includes('%') &&
|
.replace(/Editor's pick/gi, '')
|
||||||
!t.startsWith('$') &&
|
.trim();
|
||||||
!/^\d+\s*(month|year)/i.test(t),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also try to find the bank name from links or images in the row
|
|
||||||
const linkEl = row.querySelector('a[href*="review"], a[href*="bank"], img[alt]');
|
|
||||||
const linkName = linkEl?.textContent?.trim() || (linkEl as HTMLImageElement)?.alt || '';
|
|
||||||
|
|
||||||
const name = linkName.length > 3 ? linkName : bankCell || '';
|
|
||||||
if (!name) continue;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
bank_name: name,
|
|
||||||
apy_raw: apyCell,
|
|
||||||
min_deposit_raw:
|
|
||||||
texts.find((t) => t.includes('$') || /no min/i.test(t)) || '',
|
|
||||||
term_raw: texts.find((t) => /\d+\s*(month|year)/i.test(t)) || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length >= maxRates) break;
|
|
||||||
}
|
|
||||||
if (results.length >= 5) break; // Found a good table
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Look for card/list layouts with bank names and rates
|
|
||||||
if (results.length < 5) {
|
|
||||||
const cardSelectors = [
|
|
||||||
'[class*="product"]',
|
|
||||||
'[class*="offer-card"]',
|
|
||||||
'[class*="rate-card"]',
|
|
||||||
'[class*="ComparisonRow"]',
|
|
||||||
'[class*="comparison-row"]',
|
|
||||||
'[data-testid*="product"]',
|
|
||||||
'[class*="partner"]',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const selector of cardSelectors) {
|
|
||||||
const cards = document.querySelectorAll(selector);
|
|
||||||
if (cards.length < 3) continue;
|
|
||||||
|
|
||||||
for (const card of cards) {
|
|
||||||
const text = card.textContent || '';
|
|
||||||
if (text.length < 20 || text.length > 2000) continue;
|
|
||||||
|
|
||||||
const apyMatch = text.match(/([\d.]+)\s*%/);
|
|
||||||
if (!apyMatch) continue;
|
|
||||||
|
|
||||||
// Try to find bank name from heading, link, or image alt text
|
|
||||||
const nameEl =
|
|
||||||
card.querySelector(
|
|
||||||
'h2, h3, h4, h5, strong, [class*="name"], [class*="bank"], [class*="title"], a[href*="review"], img[alt]',
|
|
||||||
);
|
|
||||||
let bankName = nameEl?.textContent?.trim() || (nameEl as HTMLImageElement)?.alt || '';
|
|
||||||
|
|
||||||
// Skip if the "name" is just a rate or term
|
|
||||||
if (!bankName || bankName.length < 3 || /^\d/.test(bankName) || bankName.includes('%')) continue;
|
|
||||||
|
|
||||||
const depositMatch = text.match(/\$[\d,]+/);
|
|
||||||
const termMatch = text.match(/\d+\s*(?:month|year)s?/i);
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
bank_name: bankName,
|
|
||||||
apy_raw: apyMatch[0],
|
|
||||||
min_deposit_raw: depositMatch?.[0] || '',
|
|
||||||
term_raw: termMatch?.[0] || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length >= maxRates) break;
|
|
||||||
}
|
}
|
||||||
if (results.length >= 5) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: extract from product name pattern (e.g. "NexBank CD")
|
||||||
|
if (!bankName) {
|
||||||
|
const productMatch = text.match(/^(?:Editor's pick)?\s*([A-Z][\w\s®*.'&-]+?(?:CD|Account|Savings|Money Market))/);
|
||||||
|
if (productMatch) bankName = productMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bankName || bankName.length < 2) continue;
|
||||||
|
|
||||||
|
// APY: find the percentage that appears after "APY as of" context.
|
||||||
|
// Avoid picking up the Bankrate score (e.g. "4.5 Bankrate CD score").
|
||||||
|
// Use \b or (?<!\d) to avoid capturing leading periods from adjacent text.
|
||||||
|
let apyRaw = '';
|
||||||
|
const apySection = text.match(/APY as of[\s\S]*?(\d+\.?\d*)\s*%/);
|
||||||
|
if (apySection) {
|
||||||
|
apyRaw = apySection[1] + '%';
|
||||||
|
} else {
|
||||||
|
// Broader fallback: find "X.XX% APY" or just "X.XX%"
|
||||||
|
const apyMatch = text.match(/(\d+\.?\d*)\s*%\s*(?:APY)?/);
|
||||||
|
if (apyMatch) apyRaw = apyMatch[1] + '%';
|
||||||
|
}
|
||||||
|
if (!apyRaw) continue;
|
||||||
|
|
||||||
|
// Min. deposit: CDs use "Min. deposit $X", savings/MM use "Min. balance for APY$X"
|
||||||
|
let minDepositRaw = '';
|
||||||
|
const minDepMatch = text.match(/Min\.\s*deposit\s*\$\s*([\d,]+)/i);
|
||||||
|
const minBalMatch = text.match(/Min\.\s*balance\s*for\s*APY\s*\$\s*([\d,.]+)/i);
|
||||||
|
const noMin = /No minimum/i.test(text);
|
||||||
|
if (noMin) {
|
||||||
|
minDepositRaw = '$0';
|
||||||
|
} else if (minDepMatch) {
|
||||||
|
minDepositRaw = '$' + minDepMatch[1];
|
||||||
|
} else if (minBalMatch) {
|
||||||
|
minDepositRaw = '$' + minBalMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Term: CDs have terms like "1yr", "14mo", "1.5yr"
|
||||||
|
let termRaw = '';
|
||||||
|
const termMatch = text.match(/Term\s*([\d.]+)\s*(yr|mo|year|month)s?/i);
|
||||||
|
if (termMatch) {
|
||||||
|
const num = termMatch[1];
|
||||||
|
const unit = termMatch[2].toLowerCase();
|
||||||
|
if (unit === 'yr' || unit === 'year') {
|
||||||
|
termRaw = `${num} year${num === '1' ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
termRaw = `${num} month${num === '1' ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
bank_name: bankName,
|
||||||
|
apy_raw: apyRaw,
|
||||||
|
min_deposit_raw: minDepositRaw,
|
||||||
|
term_raw: termRaw,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length >= maxRates) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Broad scan for rate-bearing elements
|
// Fallback strategy: if card-based extraction found nothing,
|
||||||
if (results.length < 5) {
|
// scan for any elements with bank-like names and APY percentages.
|
||||||
const allElements = document.querySelectorAll(
|
// This guards against future Bankrate layout changes.
|
||||||
'div, section, article, li',
|
if (results.length === 0) {
|
||||||
|
const fallbackCards = document.querySelectorAll(
|
||||||
|
'[class*="product"], [class*="offer"], [class*="rate-card"], [class*="ComparisonRow"]',
|
||||||
);
|
);
|
||||||
for (const el of allElements) {
|
for (const card of fallbackCards) {
|
||||||
if (el.children.length > 20) continue;
|
const text = card.textContent || '';
|
||||||
const text = el.textContent || '';
|
if (text.length < 20 || text.length > 2000) continue;
|
||||||
if (text.length < 20 || text.length > 500) continue;
|
|
||||||
|
|
||||||
const apyMatch = text.match(/([\d.]+)\s*%\s*(?:APY)?/i);
|
const apyMatch = text.match(/(\d+\.?\d*)\s*%\s*(?:APY)?/);
|
||||||
if (!apyMatch) continue;
|
if (!apyMatch) continue;
|
||||||
|
|
||||||
const bankEl = el.querySelector(
|
const nameEl = card.querySelector('img[alt], h2, h3, h4, h5, [class*="name"], [class*="bank"]');
|
||||||
'h2, h3, h4, h5, strong, b, a[href*="review"]',
|
const bankName = (nameEl as HTMLImageElement)?.alt
|
||||||
);
|
|| nameEl?.textContent?.trim()
|
||||||
let bankName = bankEl?.textContent?.trim() || '';
|
|| '';
|
||||||
if (!bankName || bankName.length < 3 || /^\d/.test(bankName)) continue;
|
if (!bankName || bankName.length < 2 || /^\d/.test(bankName) || bankName.includes('%')) continue;
|
||||||
|
|
||||||
const depositMatch = text.match(/\$[\d,]+/);
|
const depositMatch = text.match(/\$[\d,]+/);
|
||||||
const termMatch = text.match(/\d+\s*(?:month|year)s?/i);
|
const termMatch = text.match(/(\d+)\s*(?:month|year)s?/i);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
bank_name: bankName,
|
bank_name: bankName,
|
||||||
apy_raw: apyMatch[0],
|
apy_raw: apyMatch[1] + '%',
|
||||||
min_deposit_raw: depositMatch?.[0] || '',
|
min_deposit_raw: depositMatch?.[0] || '',
|
||||||
term_raw: termMatch?.[0] || '',
|
term_raw: termMatch?.[0] || '',
|
||||||
});
|
});
|
||||||
@@ -261,24 +285,33 @@ async function fetchRates(): Promise<CdRate[]> {
|
|||||||
return results;
|
return results;
|
||||||
}, MAX_RATES);
|
}, MAX_RATES);
|
||||||
|
|
||||||
console.log(`Raw extraction found ${rates.length} rate entries.`);
|
console.log(`Raw extraction found ${rawRates.length} rate entries.`);
|
||||||
|
|
||||||
// Parse and normalize the scraped data
|
// Parse and normalize
|
||||||
const parsed: CdRate[] = rates
|
const isTermProduct = rateType === 'cd';
|
||||||
|
|
||||||
|
const parsed: MarketRate[] = rawRates
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
let bankName = r.bank_name.replace(/\s+/g, ' ').trim();
|
let bankName = r.bank_name
|
||||||
const term = r.term_raw || 'N/A';
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/Editor's pick/gi, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
// If the bank name looks like a term or deposit info, it's a
|
// Strip trailing product suffixes to normalize bank name
|
||||||
// summary card — label it more descriptively using the term
|
// e.g. "Marcus by Goldman Sachs CD" → "Marcus by Goldman Sachs"
|
||||||
const termText = r.term_raw || bankName;
|
bankName = bankName
|
||||||
|
.replace(/\s+(CD|Certificate of Deposit|Money Market|Savings|High[- ]Yield Savings)\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const term = isTermProduct ? (r.term_raw || 'N/A') : 'N/A';
|
||||||
|
|
||||||
|
// Skip entries where bank_name still looks like a term or number (not a real bank)
|
||||||
if (
|
if (
|
||||||
/^\d+\s*(month|year)/i.test(bankName) ||
|
/^\d+\s*(month|year)/i.test(bankName) ||
|
||||||
/no\s*min/i.test(bankName) ||
|
|
||||||
/^\$/.test(bankName) ||
|
/^\$/.test(bankName) ||
|
||||||
bankName.length < 4
|
bankName.length < 2
|
||||||
) {
|
) {
|
||||||
bankName = `Top CD Rate - ${termText.replace(/^\d+/, (m: string) => m + ' ')}`.replace(/\s+/g, ' ').trim();
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -286,13 +319,14 @@ async function fetchRates(): Promise<CdRate[]> {
|
|||||||
apy: parseApy(r.apy_raw),
|
apy: parseApy(r.apy_raw),
|
||||||
min_deposit: parseMinDeposit(r.min_deposit_raw),
|
min_deposit: parseMinDeposit(r.min_deposit_raw),
|
||||||
term,
|
term,
|
||||||
term_months: parseTermMonths(r.term_raw || bankName),
|
term_months: isTermProduct ? parseTermMonths(r.term_raw) : null,
|
||||||
|
rate_type: rateType,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((r) => r.bank_name && r.apy > 0);
|
.filter((r): r is MarketRate => r !== null && r.bank_name.length > 0 && r.apy > 0 && r.apy <= 20);
|
||||||
|
|
||||||
// Deduplicate by bank name + term (keep highest APY)
|
// Deduplicate by bank name + term (keep highest APY)
|
||||||
const seen = new Map<string, CdRate>();
|
const seen = new Map<string, MarketRate>();
|
||||||
for (const rate of parsed) {
|
for (const rate of parsed) {
|
||||||
const key = `${rate.bank_name}|${rate.term}`;
|
const key = `${rate.bank_name}|${rate.term}`;
|
||||||
const existing = seen.get(key);
|
const existing = seen.get(key);
|
||||||
@@ -305,16 +339,16 @@ async function fetchRates(): Promise<CdRate[]> {
|
|||||||
.sort((a, b) => b.apy - a.apy)
|
.sort((a, b) => b.apy - a.apy)
|
||||||
.slice(0, MAX_RATES);
|
.slice(0, MAX_RATES);
|
||||||
} finally {
|
} finally {
|
||||||
if (browser) {
|
await page.close();
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store scraped rates into shared.cd_rates, replacing all previous data.
|
* Store scraped rates into shared.cd_rates.
|
||||||
|
* Historical data is preserved — we no longer delete previous rows.
|
||||||
|
* Each fetch batch shares a common fetched_at timestamp per rate_type.
|
||||||
*/
|
*/
|
||||||
async function storeRates(rates: CdRate[]): Promise<void> {
|
async function storeRates(rates: MarketRate[], sourceUrl: string): Promise<void> {
|
||||||
const connectionString =
|
const connectionString =
|
||||||
process.env.DATABASE_URL ||
|
process.env.DATABASE_URL ||
|
||||||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
|
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
|
||||||
@@ -325,30 +359,28 @@ async function storeRates(rates: CdRate[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Clear previous batch (we only keep the latest fetch)
|
|
||||||
await client.query('DELETE FROM shared.cd_rates');
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
for (const rate of rates) {
|
for (const rate of rates) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO shared.cd_rates
|
`INSERT INTO shared.cd_rates
|
||||||
(bank_name, apy, min_deposit, term, term_months, fetched_at, source_url)
|
(bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at, source_url)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
[
|
[
|
||||||
rate.bank_name,
|
rate.bank_name,
|
||||||
rate.apy,
|
rate.apy,
|
||||||
rate.min_deposit,
|
rate.min_deposit,
|
||||||
rate.term,
|
rate.term,
|
||||||
rate.term_months,
|
rate.term_months,
|
||||||
|
rate.rate_type,
|
||||||
now,
|
now,
|
||||||
BANKRATE_URL,
|
sourceUrl,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
console.log(`Successfully stored ${rates.length} CD rates at ${now}`);
|
console.log(` Stored ${rates.length} ${rates[0]?.rate_type || ''} rates at ${now}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
throw err;
|
throw err;
|
||||||
@@ -362,41 +394,89 @@ async function storeRates(rates: CdRate[]): Promise<void> {
|
|||||||
* Main entry point.
|
* Main entry point.
|
||||||
*/
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('=== CD Rate Fetcher ===');
|
console.log('=== Market Rate Fetcher ===');
|
||||||
console.log(`Fetching top CD rates from Bankrate.com...`);
|
console.log(`Fetching rates from Bankrate.com...`);
|
||||||
console.log(`Time: ${new Date().toISOString()}`);
|
console.log(`Time: ${new Date().toISOString()}`);
|
||||||
console.log('');
|
console.log(`Rate types: ${RATE_SOURCES.map((s) => s.label).join(', ')}`);
|
||||||
|
|
||||||
|
let browser: Browser | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rates = await fetchRates();
|
// Use system Chromium if PUPPETEER_EXECUTABLE_PATH is set,
|
||||||
|
// or auto-detect common locations on Linux servers.
|
||||||
|
const executablePath =
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
['/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome'].find(
|
||||||
|
(p) => { try { require('fs').accessSync(p); return true; } catch { return false; } },
|
||||||
|
) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
if (rates.length === 0) {
|
console.log('\nLaunching headless browser...');
|
||||||
console.warn('');
|
if (executablePath) console.log(`Using browser: ${executablePath}`);
|
||||||
console.warn('WARNING: No CD rates were extracted from Bankrate.');
|
browser = await puppeteer.launch({
|
||||||
console.warn(
|
headless: true,
|
||||||
'This likely means Bankrate changed their page structure.',
|
executablePath,
|
||||||
);
|
args: [
|
||||||
console.warn(
|
'--no-sandbox',
|
||||||
'Review the page DOM and update selectors in fetch-cd-rates.ts.',
|
'--disable-setuid-sandbox',
|
||||||
);
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalStored = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < RATE_SOURCES.length; i++) {
|
||||||
|
const source = RATE_SOURCES[i];
|
||||||
|
|
||||||
|
// Pause between fetches to avoid rate limiting (skip for first)
|
||||||
|
if (i > 0) {
|
||||||
|
const pauseSeconds = 8 + Math.floor(Math.random() * 5); // 8-12 seconds
|
||||||
|
console.log(`\nPausing ${pauseSeconds} seconds before next fetch...`);
|
||||||
|
await sleep(pauseSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rates = await fetchRatesFromPage(browser, source.url, source.type, source.label);
|
||||||
|
|
||||||
|
if (rates.length === 0) {
|
||||||
|
console.warn(`\nWARNING: No ${source.label} rates were extracted.`);
|
||||||
|
console.warn('This may mean Bankrate changed their page structure.');
|
||||||
|
continue; // Don't abort the whole run — try other rate types
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nExtracted ${rates.length} ${source.label}:`);
|
||||||
|
console.log('\u2500'.repeat(80));
|
||||||
|
for (const r of rates) {
|
||||||
|
const termStr = r.term !== 'N/A' ? r.term.padEnd(15) : ''.padEnd(15);
|
||||||
|
console.log(
|
||||||
|
` ${r.bank_name.padEnd(35)} ${String(r.apy + '%').padEnd(8)} ${termStr} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('\u2500'.repeat(80));
|
||||||
|
|
||||||
|
console.log(`\nStoring ${source.label} to database...`);
|
||||||
|
await storeRates(rates, source.url);
|
||||||
|
totalStored += rates.length;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`\nERROR fetching ${source.label}: ${err.message}`);
|
||||||
|
// Continue to next rate type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalStored === 0) {
|
||||||
|
console.warn('\nWARNING: No rates were stored for any type.');
|
||||||
|
console.warn('Review Bankrate page structure and update selectors.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\nExtracted ${rates.length} rates:`);
|
console.log(`\nDone. Total rates stored: ${totalStored}`);
|
||||||
console.log('─'.repeat(70));
|
|
||||||
for (const r of rates) {
|
|
||||||
console.log(
|
|
||||||
` ${r.bank_name.padEnd(30)} ${String(r.apy + '%').padEnd(8)} ${r.term.padEnd(15)} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log('─'.repeat(70));
|
|
||||||
|
|
||||||
console.log('\nStoring to database...');
|
|
||||||
await storeRates(rates);
|
|
||||||
console.log('Done.');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('\nFATAL ERROR:', err);
|
console.error('\nFATAL ERROR:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
scripts/reset-password.sh
Executable file
150
scripts/reset-password.sh
Executable 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 ""
|
||||||
Reference in New Issue
Block a user