Compare commits
10 Commits
claude/pra
...
208c1dd7bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 208c1dd7bc | |||
| a047144922 | |||
| 508a86d16c | |||
| 16e1ada261 | |||
| 6bd080f8c4 | |||
| be3a5191c5 | |||
| 7d4df25d16 | |||
| 538828b91a | |||
| 14160854b9 | |||
| 36d486d78c |
26
backend/package-lock.json
generated
26
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
@@ -16,10 +16,12 @@
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"newrelic": "latest",
|
||||
"passport": "^0.7.0",
|
||||
@@ -1791,6 +1793,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/throttler": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/typeorm": {
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
||||
@@ -5277,6 +5290,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.3.11",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -25,11 +25,14 @@
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"newrelic": "latest",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -37,7 +40,6 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20",
|
||||
"newrelic": "latest",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { TenantMiddleware } from './database/tenant.middleware';
|
||||
@@ -52,6 +53,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
},
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000, // 1-minute window
|
||||
limit: 100, // 100 requests per minute (global default)
|
||||
}]),
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
OrganizationsModule,
|
||||
|
||||
@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
|
||||
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
// In-memory cache for org status to avoid DB hit per request
|
||||
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
||||
// In-memory cache for org info to avoid DB hit per request
|
||||
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
constructor(
|
||||
@@ -30,23 +30,25 @@ export class TenantMiddleware implements NestMiddleware {
|
||||
const token = authHeader.substring(7);
|
||||
const secret = this.configService.get<string>('JWT_SECRET');
|
||||
const decoded = jwt.verify(token, secret!) as any;
|
||||
if (decoded?.orgSchema) {
|
||||
// Check if the org is still active (catches post-JWT suspension)
|
||||
if (decoded.orgId) {
|
||||
const status = await this.getOrgStatus(decoded.orgId);
|
||||
if (status && ['suspended', 'archived'].includes(status)) {
|
||||
if (decoded?.orgId) {
|
||||
// Look up org info (status + schema) from orgId with caching
|
||||
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
||||
if (orgInfo) {
|
||||
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
||||
res.status(403).json({
|
||||
statusCode: 403,
|
||||
message: `This organization has been ${status}. Please contact your administrator.`,
|
||||
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
req.tenantSchema = orgInfo.schemaName;
|
||||
}
|
||||
|
||||
req.tenantSchema = decoded.orgSchema;
|
||||
req.orgId = decoded.orgId;
|
||||
req.userId = decoded.sub;
|
||||
req.userRole = decoded.role;
|
||||
} else if (decoded?.sub) {
|
||||
// Superadmin or user without org — still set userId
|
||||
req.userId = decoded.sub;
|
||||
}
|
||||
} catch {
|
||||
// Token invalid or expired - let Passport handle the auth error
|
||||
@@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware {
|
||||
next();
|
||||
}
|
||||
|
||||
private async getOrgStatus(orgId: string): Promise<string | null> {
|
||||
const cached = this.orgStatusCache.get(orgId);
|
||||
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
||||
const cached = this.orgCache.get(orgId);
|
||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||
return cached.status;
|
||||
return { status: cached.status, schemaName: cached.schemaName };
|
||||
}
|
||||
try {
|
||||
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],
|
||||
);
|
||||
if (result.length > 0) {
|
||||
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
||||
return result[0].status;
|
||||
this.orgCache.set(orgId, {
|
||||
status: result[0].status,
|
||||
schemaName: result[0].schemaName,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
return { status: result[0].status, schemaName: result[0].schemaName };
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — don't block requests on cache miss errors
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as os from 'node:os';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||
@@ -41,6 +42,24 @@ async function bootstrap() {
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
||||
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
||||
app.use(
|
||||
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) => {
|
||||
@@ -63,15 +82,17 @@ async function bootstrap() {
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Swagger docs — available in all environments
|
||||
// Swagger docs — disabled in production to avoid exposing API surface
|
||||
if (!isProduction) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('HOA LedgerIQ API')
|
||||
.setDescription('API for the HOA LedgerIQ')
|
||||
.setVersion('2026.3.7')
|
||||
.setVersion('2026.3.11')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
}
|
||||
|
||||
await app.listen(3000);
|
||||
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
@@ -23,12 +24,14 @@ export class AuthController {
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@UseGuards(AuthGuard('local'))
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
|
||||
@@ -118,7 +118,6 @@ export class AuthService {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
orgId: membership.organizationId,
|
||||
orgSchema: membership.organization.schemaName,
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
@@ -177,7 +176,6 @@ export class AuthService {
|
||||
|
||||
if (defaultOrg) {
|
||||
payload.orgId = defaultOrg.organizationId;
|
||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||
payload.role = defaultOrg.role;
|
||||
}
|
||||
|
||||
@@ -195,7 +193,6 @@ export class AuthService {
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
name: uo.organization?.name,
|
||||
schemaName: uo.organization?.schemaName,
|
||||
status: uo.organization?.status,
|
||||
role: uo.role,
|
||||
})),
|
||||
|
||||
@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
orgId: payload.orgId,
|
||||
orgSchema: payload.orgSchema,
|
||||
role: payload.role,
|
||||
isSuperadmin: payload.isSuperadmin || false,
|
||||
impersonatedBy: payload.impersonatedBy || null,
|
||||
|
||||
@@ -16,7 +16,7 @@ export class HealthScoresController {
|
||||
@Get('latest')
|
||||
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||
getLatest(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const schema = req.tenantSchema;
|
||||
return this.service.getLatestScores(schema);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class HealthScoresController {
|
||||
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
||||
@AllowViewer()
|
||||
async calculate(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const schema = req.tenantSchema;
|
||||
|
||||
// Fire-and-forget — background processing saves results to DB
|
||||
Promise.all([
|
||||
@@ -44,7 +44,7 @@ export class HealthScoresController {
|
||||
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
||||
@AllowViewer()
|
||||
async calculateOperating(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const schema = req.tenantSchema;
|
||||
|
||||
// Fire-and-forget
|
||||
this.service.calculateScore(schema, 'operating').catch((err) => {
|
||||
@@ -61,7 +61,7 @@ export class HealthScoresController {
|
||||
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
||||
@AllowViewer()
|
||||
async calculateReserve(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const schema = req.tenantSchema;
|
||||
|
||||
// Fire-and-forget
|
||||
this.service.calculateScore(schema, 'reserve').catch((err) => {
|
||||
|
||||
@@ -9,5 +9,34 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.15.3",
|
||||
"@mantine/dates": "^7.15.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -120,11 +120,6 @@ export function SelectOrgPage() {
|
||||
<Text fw={500}>{org.name}</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="sm" variant="light">{org.role}</Badge>
|
||||
{org.schemaName && (
|
||||
<Badge size="xs" variant="dot" color="gray">
|
||||
{org.schemaName}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
Select, Loader, Center, Badge, Card, Alert,
|
||||
} from '@mantine/core';
|
||||
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 api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
interface BudgetLine {
|
||||
account_id: string;
|
||||
@@ -95,9 +96,20 @@ function parseCSV(text: string): Record<string, string>[] {
|
||||
export function BudgetsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
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[]>({
|
||||
queryKey: ['budgets', year],
|
||||
@@ -106,25 +118,27 @@ export function BudgetsPage() {
|
||||
// Hydrate each line: ensure numbers and compute annual_total
|
||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||
setBudgetData(hydrated);
|
||||
setIsEditing(false); // Reset to view mode when year changes or data reloads
|
||||
return hydrated;
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const lines = budgetData
|
||||
const payload = budgetData
|
||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||
.map((b) => ({
|
||||
account_id: b.account_id,
|
||||
fund_type: b.fund_type,
|
||||
accountId: b.account_id,
|
||||
fundType: b.fund_type,
|
||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||
setIsEditing(false);
|
||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -221,6 +235,12 @@ export function BudgetsPage() {
|
||||
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 updated = [...budgetData];
|
||||
(updated[idx] as any)[month] = value || 0;
|
||||
@@ -275,9 +295,35 @@ export function BudgetsPage() {
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||
{hasBudget && !isEditing ? (
|
||||
<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>
|
||||
@@ -317,8 +363,8 @@ export function BudgetsPage() {
|
||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', 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: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||
{monthLabels.map((m) => (
|
||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||
))}
|
||||
@@ -337,7 +383,7 @@ export function BudgetsPage() {
|
||||
const lines = budgetData.filter((b) => b.account_type === type);
|
||||
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);
|
||||
|
||||
return [
|
||||
@@ -368,9 +414,9 @@ export function BudgetsPage() {
|
||||
style={{
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
background: 'white',
|
||||
background: stickyBg,
|
||||
zIndex: 1,
|
||||
borderRight: '1px solid #e9ecef',
|
||||
borderRight: `1px solid ${stickyBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||
@@ -379,9 +425,9 @@ export function BudgetsPage() {
|
||||
style={{
|
||||
position: 'sticky',
|
||||
left: 120,
|
||||
background: 'white',
|
||||
background: stickyBg,
|
||||
zIndex: 1,
|
||||
borderRight: '1px solid #e9ecef',
|
||||
borderRight: `1px solid ${stickyBorder}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
@@ -391,6 +437,7 @@ export function BudgetsPage() {
|
||||
</Table.Td>
|
||||
{months.map((m) => (
|
||||
<Table.Td key={m} p={2}>
|
||||
{cellsEditable ? (
|
||||
<NumberInput
|
||||
value={(line as any)[m] || 0}
|
||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||
@@ -398,9 +445,13 @@ export function BudgetsPage() {
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
disabled={isReadOnly}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" ta="right" ff="monospace">
|
||||
{fmt((line as any)[m] || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
))}
|
||||
<Table.Td ta="right" fw={500} ff="monospace">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||
@@ -79,6 +80,7 @@ export function CashFlowForecastPage() {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||
|
||||
// Filter: All, Operating, Reserve
|
||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||
@@ -418,10 +420,10 @@ export function CashFlowForecastPage() {
|
||||
<tr
|
||||
key={d.month}
|
||||
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
|
||||
? 'var(--mantine-color-orange-0)'
|
||||
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
||||
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||
|
||||
interface ActualLine {
|
||||
@@ -66,6 +67,11 @@ export function MonthlyActualsPage() {
|
||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||
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 y = new Date().getFullYear() - 2 + i;
|
||||
@@ -178,16 +184,16 @@ export function MonthlyActualsPage() {
|
||||
<Table.Tr key={line.account_id}>
|
||||
<Table.Td
|
||||
style={{
|
||||
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
||||
borderRight: '1px solid #e9ecef',
|
||||
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
|
||||
borderRight: `1px solid ${stickyBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td
|
||||
style={{
|
||||
position: 'sticky', left: 120, background: 'white', zIndex: 1,
|
||||
borderRight: '1px solid #e9ecef',
|
||||
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
|
||||
borderRight: `1px solid ${stickyBorder}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
@@ -292,10 +298,10 @@ export function MonthlyActualsPage() {
|
||||
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||
<Table.Thead>
|
||||
<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 #
|
||||
</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
|
||||
</Table.Th>
|
||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||
@@ -304,8 +310,8 @@ export function MonthlyActualsPage() {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
||||
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
||||
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
|
||||
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
interface BudgetVsActualLine {
|
||||
account_id: string;
|
||||
@@ -46,6 +47,9 @@ const monthFilterOptions = [
|
||||
export function BudgetVsActualPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
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 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) => (
|
||||
<>
|
||||
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
|
||||
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
|
||||
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
||||
</Table.Tr>
|
||||
{sectionLines.map((line) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
interface BudgetVsActualItem {
|
||||
account_id: string;
|
||||
@@ -48,6 +49,9 @@ export function QuarterlyReportPage() {
|
||||
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));
|
||||
@@ -207,7 +211,7 @@ export function QuarterlyReportPage() {
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{incomeItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||
<Table.Tr style={{ background: incomeBg }}>
|
||||
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -215,7 +219,7 @@ export function QuarterlyReportPage() {
|
||||
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||
))}
|
||||
{incomeItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||
<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>
|
||||
@@ -226,7 +230,7 @@ export function QuarterlyReportPage() {
|
||||
</Table.Tr>
|
||||
)}
|
||||
{expenseItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||
<Table.Tr style={{ background: expenseBg }}>
|
||||
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -234,7 +238,7 @@ export function QuarterlyReportPage() {
|
||||
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||
))}
|
||||
{expenseItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||
<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>
|
||||
|
||||
@@ -38,10 +38,6 @@ export function SettingsPage() {
|
||||
<Text size="sm" c="dimmed">Your Role</Text>
|
||||
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -117,7 +113,7 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">2026.3.7 (Beta)</Badge>
|
||||
<Badge variant="light">2026.03.10</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
|
||||
@@ -5,7 +5,6 @@ interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
schemaName?: string;
|
||||
status?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user