Compare commits
53 Commits
2fed5d6ce1
...
claude/ecs
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a4f27af4 | |||
| 508a86d16c | |||
| 16e1ada261 | |||
| 6bd080f8c4 | |||
| be3a5191c5 | |||
| b0282b7f8b | |||
| ac72905ecb | |||
| 7d4df25d16 | |||
| 538828b91a | |||
| 14160854b9 | |||
| 36d486d78c | |||
| 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 |
@@ -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');
|
||||||
|
}
|
||||||
1453
backend/package-lock.json
generated
1453
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.11",
|
||||||
"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,12 +23,16 @@
|
|||||||
"@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/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"newrelic": "latest",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
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 { ThrottlerModule } from '@nestjs/throttler';
|
||||||
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 +27,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,8 +44,19 @@ 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
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
ttl: 60000, // 1-minute window
|
||||||
|
limit: 100, // 100 requests per minute (global default)
|
||||||
|
}]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
@@ -62,8 +78,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()
|
||||||
)`,
|
)`,
|
||||||
@@ -324,9 +330,30 @@ export class TenantSchemaService {
|
|||||||
risk_notes JSONB,
|
risk_notes JSONB,
|
||||||
requested_by UUID,
|
requested_by UUID,
|
||||||
response_time_ms INTEGER,
|
response_time_ms INTEGER,
|
||||||
|
status VARCHAR(20) DEFAULT 'complete',
|
||||||
|
error_message TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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(),
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
// In-memory cache for org status to avoid DB hit per request
|
// In-memory cache for org info to avoid DB hit per request
|
||||||
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
||||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,23 +30,25 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgSchema) {
|
if (decoded?.orgId) {
|
||||||
// Check if the org is still active (catches post-JWT suspension)
|
// Look up org info (status + schema) from orgId with caching
|
||||||
if (decoded.orgId) {
|
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
||||||
const status = await this.getOrgStatus(decoded.orgId);
|
if (orgInfo) {
|
||||||
if (status && ['suspended', 'archived'].includes(status)) {
|
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: `This organization has been ${status}. Please contact your administrator.`,
|
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
req.tenantSchema = orgInfo.schemaName;
|
||||||
}
|
}
|
||||||
|
|
||||||
req.tenantSchema = decoded.orgSchema;
|
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
req.userRole = decoded.role;
|
req.userRole = decoded.role;
|
||||||
|
} else if (decoded?.sub) {
|
||||||
|
// Superadmin or user without org — still set userId
|
||||||
|
req.userId = decoded.sub;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid or expired - let Passport handle the auth error
|
// Token invalid or expired - let Passport handle the auth error
|
||||||
@@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrgStatus(orgId: string): Promise<string | null> {
|
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
||||||
const cached = this.orgStatusCache.get(orgId);
|
const cached = this.orgCache.get(orgId);
|
||||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
return cached.status;
|
return { status: cached.status, schemaName: cached.schemaName };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.dataSource.query(
|
const result = await this.dataSource.query(
|
||||||
`SELECT status FROM shared.organizations WHERE id = $1`,
|
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
this.orgCache.set(orgId, {
|
||||||
return result[0].status;
|
status: result[0].status,
|
||||||
|
schemaName: result[0].schemaName,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { status: result[0].status, schemaName: result[0].schemaName };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — don't block requests on cache miss errors
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
|||||||
@@ -1,18 +1,72 @@
|
|||||||
|
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 helmet from 'helmet';
|
||||||
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
|
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
||||||
app.use((req: any, _res: any, next: any) => {
|
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
||||||
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
app.use(
|
||||||
next();
|
helmet({
|
||||||
});
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://chat.hoaledgeriq.com'],
|
||||||
|
connectSrc: ["'self'", 'https://chat.hoaledgeriq.com', 'wss://chat.hoaledgeriq.com'],
|
||||||
|
imgSrc: ["'self'", 'data:', 'https://chat.hoaledgeriq.com'],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
frameSrc: ["'self'", 'https://chat.hoaledgeriq.com'],
|
||||||
|
fontSrc: ["'self'", 'data:'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request logging — only in development (too noisy / slow for prod)
|
||||||
|
if (!isProduction) {
|
||||||
|
app.use((req: any, _res: any, next: any) => {
|
||||||
|
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -22,21 +76,24 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
// Swagger docs — disabled in production to avoid exposing API surface
|
||||||
.setTitle('HOA LedgerIQ API')
|
if (!isProduction) {
|
||||||
.setDescription('API for the HOA LedgerIQ')
|
const config = new DocumentBuilder()
|
||||||
.setVersion('0.1.0')
|
.setTitle('HOA LedgerIQ API')
|
||||||
.addBearerAuth()
|
.setDescription('API for the HOA LedgerIQ')
|
||||||
.build();
|
.setVersion('2026.3.11')
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
.addBearerAuth()
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
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,
|
||||||
@@ -8,11 +9,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
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')
|
||||||
@@ -21,12 +24,14 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
@@ -42,10 +47,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'];
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ export class AuthService {
|
|||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
orgId: membership.organizationId,
|
orgId: membership.organizationId,
|
||||||
orgSchema: membership.organization.schemaName,
|
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,10 +130,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,
|
||||||
@@ -172,7 +176,6 @@ export class AuthService {
|
|||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
|
||||||
payload.role = defaultOrg.role;
|
payload.role = defaultOrg.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +188,11 @@ 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,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
schemaName: uo.organization?.schemaName,
|
|
||||||
status: uo.organization?.status,
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
orgId: payload.orgId,
|
orgId: payload.orgId,
|
||||||
orgSchema: payload.orgSchema,
|
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
impersonatedBy: payload.impersonatedBy || null,
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
|
|||||||
@@ -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.tenantSchema;
|
||||||
|
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.tenantSchema;
|
||||||
|
|
||||||
|
// 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.tenantSchema;
|
||||||
|
|
||||||
|
// 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.tenantSchema;
|
||||||
|
|
||||||
|
// 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')
|
||||||
@@ -35,8 +36,9 @@ export class InvestmentPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
status: 'processing' | 'complete' | 'error';
|
||||||
|
last_failed: boolean;
|
||||||
|
error_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -196,14 +199,33 @@ export class InvestmentPlanningService {
|
|||||||
return rates.cd;
|
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.
|
* Get the latest saved AI recommendation for this tenant.
|
||||||
|
* Returns status and last_failed flag for UI state management.
|
||||||
*/
|
*/
|
||||||
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
||||||
try {
|
try {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
response_time_ms, created_at
|
response_time_ms, status, error_message, created_at
|
||||||
FROM ai_recommendations
|
FROM ai_recommendations
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
@@ -212,6 +234,64 @@ export class InvestmentPlanningService {
|
|||||||
if (!rows || rows.length === 0) return null;
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
const row = rows[0];
|
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 || {};
|
const recData = row.recommendations_json || {};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -220,6 +300,8 @@ export class InvestmentPlanningService {
|
|||||||
risk_notes: row.risk_notes || recData.risk_notes || [],
|
risk_notes: row.risk_notes || recData.risk_notes || [],
|
||||||
response_time_ms: row.response_time_ms || 0,
|
response_time_ms: row.response_time_ms || 0,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
|
status: 'complete',
|
||||||
|
last_failed: false,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Table might not exist yet (pre-migration tenants)
|
// Table might not exist yet (pre-migration tenants)
|
||||||
@@ -228,15 +310,153 @@ export class InvestmentPlanningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* 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> {
|
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO ai_recommendations
|
`INSERT INTO ai_recommendations
|
||||||
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
|
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, 'complete')`,
|
||||||
[
|
[
|
||||||
JSON.stringify(aiResponse),
|
JSON.stringify(aiResponse),
|
||||||
aiResponse.overall_assessment || '',
|
aiResponse.overall_assessment || '',
|
||||||
@@ -873,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) => {
|
||||||
@@ -887,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++;
|
||||||
}
|
}
|
||||||
|
|||||||
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;"]
|
||||||
@@ -9,5 +9,34 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script>
|
||||||
|
(function(d,t) {
|
||||||
|
var BASE_URL="https://chat.hoaledgeriq.com";
|
||||||
|
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||||
|
g.src=BASE_URL+"/packs/js/sdk.js";
|
||||||
|
g.async=true;
|
||||||
|
s.parentNode.insertBefore(g,s);
|
||||||
|
g.onload=function(){
|
||||||
|
window.chatwootSDK.run({
|
||||||
|
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
|
||||||
|
baseUrl:BASE_URL
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})(document,"script");
|
||||||
|
window.addEventListener('chatwoot:ready', function() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('ledgeriq-auth');
|
||||||
|
if (!raw) return;
|
||||||
|
var auth = JSON.parse(raw);
|
||||||
|
var user = auth && auth.state && auth.state.user;
|
||||||
|
if (user && window.$chatwoot) {
|
||||||
|
window.$chatwoot.setUser(user.id, {
|
||||||
|
name: (user.firstName || '') + ' ' + (user.lastName || ''),
|
||||||
|
email: user.email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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.11",
|
||||||
"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>
|
||||||
|
|||||||
@@ -120,11 +120,6 @@ export function SelectOrgPage() {
|
|||||||
<Text fw={500}>{org.name}</Text>
|
<Text fw={500}>{org.name}</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Badge size="sm" variant="light">{org.role}</Badge>
|
<Badge size="sm" variant="light">{org.role}</Badge>
|
||||||
{org.schemaName && (
|
|
||||||
<Badge size="xs" variant="dot" color="gray">
|
|
||||||
{org.schemaName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
Select, Loader, Center, Badge, Card, Alert,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -94,8 +96,20 @@ function parseCSV(text: string): Record<string, string>[] {
|
|||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
|
// Budget exists when there is data loaded for the selected year
|
||||||
|
const hasBudget = budgetData.length > 0;
|
||||||
|
// Cells are editable only when editing an existing budget or creating a new one (no data yet)
|
||||||
|
const cellsEditable = !isReadOnly && (isEditing || !hasBudget);
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
@@ -104,25 +118,27 @@ export function BudgetsPage() {
|
|||||||
// Hydrate each line: ensure numbers and compute annual_total
|
// Hydrate each line: ensure numbers and compute annual_total
|
||||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||||
setBudgetData(hydrated);
|
setBudgetData(hydrated);
|
||||||
|
setIsEditing(false); // Reset to view mode when year changes or data reloads
|
||||||
return hydrated;
|
return hydrated;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = budgetData
|
const payload = budgetData
|
||||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
account_id: b.account_id,
|
accountId: b.account_id,
|
||||||
fund_type: b.fund_type,
|
fundType: b.fund_type,
|
||||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||||
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
||||||
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
|
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
|
||||||
}));
|
}));
|
||||||
return api.put(`/budgets/${year}`, { lines });
|
return api.put(`/budgets/${year}`, payload);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
setIsEditing(false);
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -219,6 +235,12 @@ export function BudgetsPage() {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// Re-fetch to discard unsaved changes
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
};
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
const updated = [...budgetData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
@@ -236,8 +258,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 +279,52 @@ 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
|
{hasBudget && !isEditing ? (
|
||||||
</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconPencil size={16} />}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
Edit Budget
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconX size={16} />}
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Save Budget
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -284,17 +338,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>
|
||||||
@@ -303,8 +363,8 @@ export function BudgetsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||||
{monthLabels.map((m) => (
|
{monthLabels.map((m) => (
|
||||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
))}
|
))}
|
||||||
@@ -323,7 +383,7 @@ export function BudgetsPage() {
|
|||||||
const lines = budgetData.filter((b) => b.account_type === type);
|
const lines = budgetData.filter((b) => b.account_type === type);
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
|
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
||||||
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -354,9 +414,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
background: 'white',
|
background: stickyBg,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
@@ -365,9 +425,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 120,
|
left: 120,
|
||||||
background: 'white',
|
background: stickyBg,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -377,15 +437,21 @@ export function BudgetsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
<Table.Td key={m} p={2}>
|
<Table.Td key={m} p={2}>
|
||||||
<NumberInput
|
{cellsEditable ? (
|
||||||
value={(line as any)[m] || 0}
|
<NumberInput
|
||||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
value={(line as any)[m] || 0}
|
||||||
size="xs"
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
hideControls
|
size="xs"
|
||||||
decimalScale={2}
|
hideControls
|
||||||
min={0}
|
decimalScale={2}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
min={0}
|
||||||
/>
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" ta="right" ff="monospace">
|
||||||
|
{fmt((line as any)[m] || 0)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
<Table.Td ta="right" fw={500} ff="monospace">
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||||
@@ -79,6 +80,7 @@ export function CashFlowForecastPage() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentYear = now.getFullYear();
|
const currentYear = now.getFullYear();
|
||||||
const currentMonth = now.getMonth() + 1;
|
const currentMonth = now.getMonth() + 1;
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
|
||||||
// Filter: All, Operating, Reserve
|
// Filter: All, Operating, Reserve
|
||||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||||
@@ -418,10 +420,10 @@ export function CashFlowForecastPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={d.month}
|
key={d.month}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
|
||||||
backgroundColor: d.is_forecast
|
backgroundColor: d.is_forecast
|
||||||
? 'var(--mantine-color-orange-0)'
|
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
|
||||||
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||||
|
|||||||
@@ -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, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
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';
|
||||||
|
|
||||||
@@ -107,6 +107,9 @@ interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
status: 'processing' | 'complete' | 'error';
|
||||||
|
last_failed: boolean;
|
||||||
|
error_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
@@ -181,14 +184,29 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
|
|||||||
|
|
||||||
// ── Recommendations Display Component ──
|
// ── Recommendations Display Component ──
|
||||||
|
|
||||||
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
|
function RecommendationsDisplay({
|
||||||
|
aiResult,
|
||||||
|
lastUpdated,
|
||||||
|
lastFailed,
|
||||||
|
}: {
|
||||||
|
aiResult: AIResponse;
|
||||||
|
lastUpdated?: string;
|
||||||
|
lastFailed?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{/* Last Updated timestamp */}
|
{/* Last Updated timestamp + failure message */}
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
<Stack gap={0} align="flex-end">
|
||||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
</Text>
|
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 */}
|
{/* Overall Assessment */}
|
||||||
@@ -327,9 +345,8 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons
|
|||||||
// ── Main Component ──
|
// ── Main Component ──
|
||||||
|
|
||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
|
||||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
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>({
|
||||||
@@ -349,50 +366,86 @@ export function InvestmentPlanningPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load saved recommendation on mount
|
// Load saved recommendation — polls every 3s when processing
|
||||||
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||||
queryKey: ['investment-planning-saved-recommendation'],
|
queryKey: ['investment-planning-saved-recommendation'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const rec = query.state.data;
|
||||||
|
// Poll every 3 seconds while processing
|
||||||
|
if (rec?.status === 'processing') return 3000;
|
||||||
|
// Also poll if we just triggered (status may not be 'processing' yet)
|
||||||
|
if (isTriggering) return 3000;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate AI results from saved recommendation on load
|
// Derive display state from saved recommendation
|
||||||
useEffect(() => {
|
const isProcessing = savedRec?.status === 'processing' || isTriggering;
|
||||||
if (savedRec && !aiResult) {
|
const lastFailed = savedRec?.last_failed || false;
|
||||||
setAiResult({
|
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
|
||||||
recommendations: savedRec.recommendations,
|
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
|
||||||
overall_assessment: savedRec.overall_assessment,
|
|
||||||
risk_notes: savedRec.risk_notes,
|
|
||||||
});
|
|
||||||
setLastUpdated(savedRec.created_at);
|
|
||||||
}
|
|
||||||
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// AI recommendation (on-demand)
|
// Clear triggering flag once backend confirms processing or completes
|
||||||
const aiMutation = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: async () => {
|
if (isTriggering && savedRec?.status === 'processing') {
|
||||||
const { data } = await api.post('/investment-planning/recommendations');
|
setIsTriggering(false);
|
||||||
return data as AIResponse;
|
}
|
||||||
},
|
if (isTriggering && savedRec?.status === 'complete') {
|
||||||
onSuccess: (data) => {
|
setIsTriggering(false);
|
||||||
setAiResult(data);
|
}
|
||||||
setLastUpdated(new Date().toISOString());
|
}, [savedRec?.status, isTriggering]);
|
||||||
if (data.recommendations.length > 0) {
|
|
||||||
notifications.show({
|
// Show notification when processing completes (transition from processing)
|
||||||
message: `Generated ${data.recommendations.length} investment recommendations`,
|
const prevStatusRef = useState<string | null>(null);
|
||||||
color: 'green',
|
useEffect(() => {
|
||||||
});
|
const [prevStatus, setPrevStatus] = prevStatusRef;
|
||||||
}
|
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
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',
|
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 (
|
||||||
@@ -645,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' }}
|
||||||
>
|
>
|
||||||
@@ -654,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" />
|
||||||
@@ -663,19 +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 && (
|
||||||
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
|
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
|
||||||
|
<Text size="sm">
|
||||||
|
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results (with optional failure watermark) */}
|
||||||
|
{aiResult && !isProcessing && (
|
||||||
|
<RecommendationsDisplay
|
||||||
|
aiResult={aiResult}
|
||||||
|
lastUpdated={savedRec?.created_at || undefined}
|
||||||
|
lastFailed={lastFailed}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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} />
|
||||||
|
|||||||
@@ -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,8 @@ 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 { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -64,6 +66,12 @@ 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 isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
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;
|
||||||
@@ -176,16 +184,16 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Tr key={line.account_id}>
|
<Table.Tr key={line.account_id}>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 120, background: 'white', zIndex: 1,
|
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -204,6 +212,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 +238,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>
|
||||||
|
|
||||||
@@ -287,10 +298,10 @@ export function MonthlyActualsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
|
||||||
Acct #
|
Acct #
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
|
||||||
Account Name
|
Account Name
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||||
@@ -299,8 +310,8 @@ export function MonthlyActualsPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
|
||||||
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetVsActualLine {
|
interface BudgetVsActualLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -46,6 +47,9 @@ const monthFilterOptions = [
|
|||||||
export function BudgetVsActualPage() {
|
export function BudgetVsActualPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [month, setMonth] = useState('');
|
const [month, setMonth] = useState('');
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
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;
|
||||||
@@ -92,7 +96,7 @@ export function BudgetVsActualPage() {
|
|||||||
|
|
||||||
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
||||||
<>
|
<>
|
||||||
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
|
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
|
||||||
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
{sectionLines.map((line) => {
|
{sectionLines.map((line) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
296
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
296
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
|
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 isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
|
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: incomeBg }}>
|
||||||
|
<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: incomeBg }}>
|
||||||
|
<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: expenseBg }}>
|
||||||
|
<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: expenseBg }}>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ export function SettingsPage() {
|
|||||||
<Text size="sm" c="dimmed">Your Role</Text>
|
<Text size="sm" c="dimmed">Your Role</Text>
|
||||||
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Schema</Text>
|
|
||||||
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -117,7 +113,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.03.10</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>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ interface Organization {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
schemaName?: string;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -16,6 +16,7 @@ interface User {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
isSuperadmin?: boolean;
|
isSuperadmin?: boolean;
|
||||||
isPlatformOwner?: boolean;
|
isPlatformOwner?: boolean;
|
||||||
|
hasSeenIntro?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImpersonationOriginal {
|
interface ImpersonationOriginal {
|
||||||
@@ -33,11 +34,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 +65,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 +113,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
|
||||||
@@ -61,12 +61,10 @@ interface MarketRate {
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -84,10 +82,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,8 +100,20 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a Bankrate URL and scrape rate data.
|
* Navigate to a Bankrate URL and scrape rate data from individual bank offer cards.
|
||||||
* Reuses an existing browser instance.
|
*
|
||||||
|
* 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(
|
async function fetchRatesFromPage(
|
||||||
browser: Browser,
|
browser: Browser,
|
||||||
@@ -109,7 +123,7 @@ async function fetchRatesFromPage(
|
|||||||
): Promise<MarketRate[]> {
|
): Promise<MarketRate[]> {
|
||||||
const page: Page = await browser.newPage();
|
const page: Page = await browser.newPage();
|
||||||
await page.setUserAgent(
|
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',
|
'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 {
|
||||||
@@ -120,13 +134,13 @@ async function fetchRatesFromPage(
|
|||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for rate content to render
|
// Wait for rate card sections to render
|
||||||
console.log('Waiting for rate data to render...');
|
console.log('Waiting for rate cards 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
|
||||||
@@ -143,7 +157,7 @@ async function fetchRatesFromPage(
|
|||||||
});
|
});
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
// Extract rate data from the page
|
// Extract rate data from individual bank offer cards
|
||||||
const rawRates = await page.evaluate((maxRates: number) => {
|
const rawRates = await page.evaluate((maxRates: number) => {
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
@@ -152,120 +166,114 @@ async function fetchRatesFromPage(
|
|||||||
term_raw: string;
|
term_raw: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// Strategy 1: Look for detailed bank comparison tables
|
// Primary strategy: extract from Bankrate offer cards
|
||||||
const tables = document.querySelectorAll('table');
|
// Both sponsored and additional offer sections use the same card structure
|
||||||
for (const table of tables) {
|
const cards = [
|
||||||
const rows = table.querySelectorAll('tbody tr');
|
...document.querySelectorAll('.wrt-RateSections-sponsoredoffers > .rounded-md'),
|
||||||
if (rows.length < 3) continue;
|
...document.querySelectorAll('.wrt-RateSections-additionaloffers > .rounded-md'),
|
||||||
|
];
|
||||||
|
|
||||||
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() : '';
|
||||||
|
|
||||||
const bankCell = texts.find(
|
// Fallback: extract from text before "Add to compare"
|
||||||
(t) =>
|
if (!bankName) {
|
||||||
t.length > 3 &&
|
const addIdx = text.indexOf('Add to compare');
|
||||||
!/^\d/.test(t) &&
|
if (addIdx > 0) {
|
||||||
!t.includes('%') &&
|
bankName = text.substring(0, addIdx)
|
||||||
!t.startsWith('$') &&
|
.replace(/Editor's pick/gi, '')
|
||||||
!/^\d+\s*(month|year)/i.test(t),
|
.trim();
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Look for card/list layouts
|
|
||||||
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;
|
|
||||||
|
|
||||||
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 || '';
|
|
||||||
|
|
||||||
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] || '',
|
||||||
});
|
});
|
||||||
@@ -284,20 +292,26 @@ async function fetchRatesFromPage(
|
|||||||
|
|
||||||
const parsed: MarketRate[] = rawRates
|
const parsed: MarketRate[] = rawRates
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
let bankName = r.bank_name.replace(/\s+/g, ' ').trim();
|
let bankName = r.bank_name
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/Editor's pick/gi, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Strip trailing product suffixes to normalize bank name
|
||||||
|
// e.g. "Marcus by Goldman Sachs CD" → "Marcus by Goldman Sachs"
|
||||||
|
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';
|
const term = isTermProduct ? (r.term_raw || 'N/A') : 'N/A';
|
||||||
|
|
||||||
// For CDs: if bank name looks like a term, label it descriptively
|
// Skip entries where bank_name still looks like a term or number (not a real bank)
|
||||||
if (isTermProduct) {
|
if (
|
||||||
const termText = r.term_raw || bankName;
|
/^\d+\s*(month|year)/i.test(bankName) ||
|
||||||
if (
|
/^\$/.test(bankName) ||
|
||||||
/^\d+\s*(month|year)/i.test(bankName) ||
|
bankName.length < 2
|
||||||
/no\s*min/i.test(bankName) ||
|
) {
|
||||||
/^\$/.test(bankName) ||
|
return null;
|
||||||
bankName.length < 4
|
|
||||||
) {
|
|
||||||
bankName = `Top CD Rate - ${termText.replace(/^\d+/, (m: string) => m + ' ')}`.replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -305,11 +319,11 @@ async function fetchRatesFromPage(
|
|||||||
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: isTermProduct ? parseTermMonths(r.term_raw || bankName) : null,
|
term_months: isTermProduct ? parseTermMonths(r.term_raw) : null,
|
||||||
rate_type: rateType,
|
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, MarketRate>();
|
const seen = new Map<string, MarketRate>();
|
||||||
@@ -388,9 +402,20 @@ async function main() {
|
|||||||
let browser: Browser | null = null;
|
let browser: Browser | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 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;
|
||||||
|
|
||||||
console.log('\nLaunching headless browser...');
|
console.log('\nLaunching headless browser...');
|
||||||
|
if (executablePath) console.log(`Using browser: ${executablePath}`);
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
|
executablePath,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
|
|||||||
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