Sprint 6: Monthly actuals input, reconciliation, and file attachments
Add spreadsheet-style Monthly Actuals page for entering monthly actuals against budget with auto-generated journal entries and reconciliation flag. Add file attachment support (PDF, images, spreadsheets) on journal entries for receipts and invoices. Enhance Budget vs Actual report with month filter dropdown. Add reconciled badge to Transactions page. Replace bcrypt with bcryptjs to fix Docker cross-platform native binding issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8878
backend/package-lock.json
generated
Normal file
8878
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcrypt": "^5.1.1",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
@@ -42,9 +42,10 @@
|
|||||||
"@nestjs/cli": "^10.4.9",
|
"@nestjs/cli": "^10.4.9",
|
||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/testing": "^10.4.15",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.17.12",
|
"@types/node": "^20.17.12",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
@@ -56,13 +57,23 @@
|
|||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
"transform": {
|
||||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": { "^@/(.*)$": "<rootDir>/$1" }
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { CapitalProjectsModule } from './modules/capital-projects/capital-projec
|
|||||||
import { ReportsModule } from './modules/reports/reports.module';
|
import { ReportsModule } from './modules/reports/reports.module';
|
||||||
import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module';
|
import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module';
|
||||||
import { ProjectsModule } from './modules/projects/projects.module';
|
import { ProjectsModule } from './modules/projects/projects.module';
|
||||||
|
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||||
|
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -56,6 +58,8 @@ import { ProjectsModule } from './modules/projects/projects.module';
|
|||||||
ReportsModule,
|
ReportsModule,
|
||||||
AssessmentGroupsModule,
|
AssessmentGroupsModule,
|
||||||
ProjectsModule,
|
ProjectsModule,
|
||||||
|
MonthlyActualsModule,
|
||||||
|
AttachmentsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class TenantSchemaService {
|
|||||||
reference_number VARCHAR(100),
|
reference_number VARCHAR(100),
|
||||||
entry_type VARCHAR(50) NOT NULL CHECK (entry_type IN (
|
entry_type VARCHAR(50) NOT NULL CHECK (entry_type IN (
|
||||||
'manual', 'assessment', 'payment', 'late_fee', 'transfer',
|
'manual', 'assessment', 'payment', 'late_fee', 'transfer',
|
||||||
'adjustment', 'closing', 'opening_balance'
|
'adjustment', 'closing', 'opening_balance', 'monthly_actual'
|
||||||
)),
|
)),
|
||||||
fiscal_period_id UUID NOT NULL REFERENCES "${s}".fiscal_periods(id),
|
fiscal_period_id UUID NOT NULL REFERENCES "${s}".fiscal_periods(id),
|
||||||
source_type VARCHAR(50),
|
source_type VARCHAR(50),
|
||||||
@@ -81,6 +81,7 @@ export class TenantSchemaService {
|
|||||||
posted_by UUID,
|
posted_by UUID,
|
||||||
posted_at TIMESTAMPTZ,
|
posted_at TIMESTAMPTZ,
|
||||||
is_void BOOLEAN DEFAULT FALSE,
|
is_void BOOLEAN DEFAULT FALSE,
|
||||||
|
is_reconciled BOOLEAN DEFAULT FALSE,
|
||||||
voided_by UUID,
|
voided_by UUID,
|
||||||
voided_at TIMESTAMPTZ,
|
voided_at TIMESTAMPTZ,
|
||||||
void_reason TEXT,
|
void_reason TEXT,
|
||||||
@@ -313,7 +314,20 @@ export class TenantSchemaService {
|
|||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Attachments (file storage for receipts/invoices)
|
||||||
|
`CREATE TABLE "${s}".attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
journal_entry_id UUID NOT NULL REFERENCES "${s}".journal_entries(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
file_data BYTEA NOT NULL,
|
||||||
|
uploaded_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
// Indexes
|
// Indexes
|
||||||
|
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||||
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
|
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
|
||||||
`CREATE INDEX "idx_${s}_jel_entry" ON "${s}".journal_entry_lines(journal_entry_id)`,
|
`CREATE INDEX "idx_${s}_jel_entry" ON "${s}".journal_entry_lines(journal_entry_id)`,
|
||||||
|
|||||||
55
backend/src/modules/attachments/attachments.controller.ts
Normal file
55
backend/src/modules/attachments/attachments.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller, Get, Post, Delete, Param, UseGuards, Request, Res,
|
||||||
|
UseInterceptors, UploadedFile,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AttachmentsService } from './attachments.service';
|
||||||
|
|
||||||
|
@ApiTags('attachments')
|
||||||
|
@Controller('attachments')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AttachmentsController {
|
||||||
|
constructor(private attachmentsService: AttachmentsService) {}
|
||||||
|
|
||||||
|
@Post('journal-entry/:journalEntryId')
|
||||||
|
@ApiOperation({ summary: 'Upload an attachment for a journal entry' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
|
}))
|
||||||
|
async upload(
|
||||||
|
@Param('journalEntryId') journalEntryId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
return this.attachmentsService.upload(journalEntryId, file, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('journal-entry/:journalEntryId')
|
||||||
|
@ApiOperation({ summary: 'List attachments for a journal entry' })
|
||||||
|
async listByJournalEntry(@Param('journalEntryId') journalEntryId: string) {
|
||||||
|
return this.attachmentsService.findByJournalEntry(journalEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/download')
|
||||||
|
@ApiOperation({ summary: 'Download an attachment' })
|
||||||
|
async download(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
const attachment = await this.attachmentsService.download(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': attachment.mime_type,
|
||||||
|
'Content-Disposition': `attachment; filename="${attachment.filename}"`,
|
||||||
|
'Content-Length': attachment.file_data.length,
|
||||||
|
});
|
||||||
|
res.send(attachment.file_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete an attachment' })
|
||||||
|
async delete(@Param('id') id: string) {
|
||||||
|
return this.attachmentsService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/modules/attachments/attachments.module.ts
Normal file
10
backend/src/modules/attachments/attachments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AttachmentsController } from './attachments.controller';
|
||||||
|
import { AttachmentsService } from './attachments.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AttachmentsController],
|
||||||
|
providers: [AttachmentsService],
|
||||||
|
exports: [AttachmentsService],
|
||||||
|
})
|
||||||
|
export class AttachmentsModule {}
|
||||||
78
backend/src/modules/attachments/attachments.service.ts
Normal file
78
backend/src/modules/attachments/attachments.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
const ALLOWED_MIMES = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AttachmentsService {
|
||||||
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
async upload(journalEntryId: string, file: Express.Multer.File, userId: string) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('No file provided');
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw new BadRequestException('File size exceeds 10MB limit');
|
||||||
|
}
|
||||||
|
if (!ALLOWED_MIMES.includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File type "${file.mimetype}" not allowed. Allowed: PDF, JPEG, PNG, WebP, GIF, XLS, XLSX`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify journal entry exists
|
||||||
|
const jeRows = await this.tenant.query(
|
||||||
|
`SELECT id FROM journal_entries WHERE id = $1`,
|
||||||
|
[journalEntryId],
|
||||||
|
);
|
||||||
|
if (!jeRows.length) {
|
||||||
|
throw new NotFoundException('Journal entry not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO attachments (journal_entry_id, filename, mime_type, file_size, file_data, uploaded_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, filename, mime_type, file_size, created_at`,
|
||||||
|
[journalEntryId, file.originalname, file.mimetype, file.size, file.buffer, userId],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByJournalEntry(journalEntryId: string) {
|
||||||
|
return this.tenant.query(
|
||||||
|
`SELECT id, journal_entry_id, filename, mime_type, file_size, created_at
|
||||||
|
FROM attachments
|
||||||
|
WHERE journal_entry_id = $1
|
||||||
|
ORDER BY created_at`,
|
||||||
|
[journalEntryId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(id: string) {
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT filename, mime_type, file_data FROM attachments WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rows.length) throw new NotFoundException('Attachment not found');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const result = await this.tenant.query(
|
||||||
|
`DELETE FROM attachments WHERE id = $1 RETURNING id`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!result.length) throw new NotFoundException('Attachment not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class CreateJournalEntryDto {
|
|||||||
referenceNumber?: string;
|
referenceNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'manual', required: false })
|
@ApiProperty({ example: 'manual', required: false })
|
||||||
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance'])
|
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance', 'monthly_actual'])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
entryType?: string;
|
entryType?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||||
|
|
||||||
|
@ApiTags('monthly-actuals')
|
||||||
|
@Controller('monthly-actuals')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class MonthlyActualsController {
|
||||||
|
constructor(private monthlyActualsService: MonthlyActualsService) {}
|
||||||
|
|
||||||
|
@Get(':year/:month')
|
||||||
|
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||||
|
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||||
|
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':year/:month')
|
||||||
|
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||||
|
async save(
|
||||||
|
@Param('year') year: string,
|
||||||
|
@Param('month') month: string,
|
||||||
|
@Body() body: { lines: { accountId: string; amount: number }[] },
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
return this.monthlyActualsService.saveActuals(
|
||||||
|
parseInt(year),
|
||||||
|
parseInt(month),
|
||||||
|
body.lines,
|
||||||
|
req.user.sub,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MonthlyActualsController } from './monthly-actuals.controller';
|
||||||
|
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||||
|
import { JournalEntriesModule } from '../journal-entries/journal-entries.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [JournalEntriesModule],
|
||||||
|
controllers: [MonthlyActualsController],
|
||||||
|
providers: [MonthlyActualsService],
|
||||||
|
})
|
||||||
|
export class MonthlyActualsModule {}
|
||||||
191
backend/src/modules/monthly-actuals/monthly-actuals.service.ts
Normal file
191
backend/src/modules/monthly-actuals/monthly-actuals.service.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
import { JournalEntriesService } from '../journal-entries/journal-entries.service';
|
||||||
|
|
||||||
|
const MONTH_COLS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
|
const MONTH_LABELS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MonthlyActualsService {
|
||||||
|
constructor(
|
||||||
|
private tenant: TenantService,
|
||||||
|
private journalEntriesService: JournalEntriesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getActualsGrid(year: number, month: number) {
|
||||||
|
if (month < 1 || month > 12) throw new BadRequestException('Month must be 1-12');
|
||||||
|
|
||||||
|
const budgetCol = MONTH_COLS[month - 1];
|
||||||
|
|
||||||
|
// Get all income/expense accounts with budget amounts and existing actuals
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT
|
||||||
|
a.id as account_id,
|
||||||
|
a.account_number,
|
||||||
|
a.name as account_name,
|
||||||
|
a.account_type,
|
||||||
|
a.fund_type,
|
||||||
|
COALESCE(b.${budgetCol}, 0) as budget_amount,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
|
||||||
|
ELSE jel.credit - jel.debit END
|
||||||
|
), 0) as actual_amount
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||||
|
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
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
AND EXTRACT(MONTH FROM je.entry_date) = $2
|
||||||
|
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,
|
||||||
|
b.${budgetCol}
|
||||||
|
ORDER BY a.account_number`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if there's an existing monthly_actual journal entry for this month
|
||||||
|
const existingJE = await this.tenant.query(
|
||||||
|
`SELECT id FROM journal_entries
|
||||||
|
WHERE entry_type = 'monthly_actual'
|
||||||
|
AND EXTRACT(YEAR FROM entry_date) = $1
|
||||||
|
AND EXTRACT(MONTH FROM entry_date) = $2
|
||||||
|
AND is_void = false
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
month_label: MONTH_LABELS[month - 1],
|
||||||
|
existing_journal_entry_id: existingJE[0]?.id || null,
|
||||||
|
lines: rows.map((r: any) => ({
|
||||||
|
...r,
|
||||||
|
budget_amount: parseFloat(r.budget_amount || '0'),
|
||||||
|
actual_amount: parseFloat(r.actual_amount || '0'),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveActuals(year: number, month: number, lines: { accountId: string; amount: number }[], userId: string) {
|
||||||
|
if (month < 1 || month > 12) throw new BadRequestException('Month must be 1-12');
|
||||||
|
|
||||||
|
const monthLabel = MONTH_LABELS[month - 1];
|
||||||
|
|
||||||
|
// 1. Void any existing monthly_actual entries for this year/month
|
||||||
|
const existingEntries = await this.tenant.query(
|
||||||
|
`SELECT id FROM journal_entries
|
||||||
|
WHERE entry_type = 'monthly_actual'
|
||||||
|
AND EXTRACT(YEAR FROM entry_date) = $1
|
||||||
|
AND EXTRACT(MONTH FROM entry_date) = $2
|
||||||
|
AND is_void = false`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const entry of existingEntries) {
|
||||||
|
await this.journalEntriesService.void(entry.id, userId, `Replaced by updated monthly actuals for ${monthLabel} ${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find primary operating cash account (offset account for double-entry)
|
||||||
|
let cashAccounts = await this.tenant.query(
|
||||||
|
`SELECT id FROM accounts WHERE is_primary = true AND fund_type = 'operating' AND account_type = 'asset' LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (!cashAccounts.length) {
|
||||||
|
cashAccounts = await this.tenant.query(
|
||||||
|
`SELECT id FROM accounts WHERE account_number = '1000' AND account_type = 'asset' LIMIT 1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!cashAccounts.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No primary cash account found. Please set a primary operating account on the Accounts page.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cashAccountId = cashAccounts[0].id;
|
||||||
|
|
||||||
|
// 3. Filter to lines with actual amounts
|
||||||
|
const filteredLines = lines.filter((l) => l.amount !== 0);
|
||||||
|
if (filteredLines.length === 0) {
|
||||||
|
return { message: 'No actuals to save', journal_entry_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Look up account types for each line
|
||||||
|
const accountIds = filteredLines.map((l) => l.accountId);
|
||||||
|
const accountRows = await this.tenant.query(
|
||||||
|
`SELECT id, account_type FROM accounts WHERE id = ANY($1)`,
|
||||||
|
[accountIds],
|
||||||
|
);
|
||||||
|
const accountTypeMap = new Map(accountRows.map((a: any) => [a.id, a.account_type]));
|
||||||
|
|
||||||
|
// 5. Build journal entry lines
|
||||||
|
const jeLines: any[] = [];
|
||||||
|
let totalCashDebit = 0;
|
||||||
|
let totalCashCredit = 0;
|
||||||
|
|
||||||
|
for (const line of filteredLines) {
|
||||||
|
const acctType = accountTypeMap.get(line.accountId);
|
||||||
|
if (!acctType) continue;
|
||||||
|
|
||||||
|
if (acctType === 'expense') {
|
||||||
|
// Expense: debit expense account, credit cash
|
||||||
|
jeLines.push({
|
||||||
|
accountId: line.accountId,
|
||||||
|
debit: Math.abs(line.amount),
|
||||||
|
credit: 0,
|
||||||
|
memo: `${monthLabel} actual`,
|
||||||
|
});
|
||||||
|
totalCashCredit += Math.abs(line.amount);
|
||||||
|
} else if (acctType === 'income') {
|
||||||
|
// Income: credit income account, debit cash
|
||||||
|
jeLines.push({
|
||||||
|
accountId: line.accountId,
|
||||||
|
debit: 0,
|
||||||
|
credit: Math.abs(line.amount),
|
||||||
|
memo: `${monthLabel} actual`,
|
||||||
|
});
|
||||||
|
totalCashDebit += Math.abs(line.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Add offsetting cash line(s) to balance the entry
|
||||||
|
const netCash = totalCashDebit - totalCashCredit;
|
||||||
|
if (netCash > 0) {
|
||||||
|
jeLines.push({ accountId: cashAccountId, debit: netCash, credit: 0, memo: `${monthLabel} actuals offset` });
|
||||||
|
} else if (netCash < 0) {
|
||||||
|
jeLines.push({ accountId: cashAccountId, debit: 0, credit: Math.abs(netCash), memo: `${monthLabel} actuals offset` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Set entry_date to last day of the month
|
||||||
|
const lastDay = new Date(year, month, 0); // month is 1-indexed; new Date(2026, 1, 0) = Jan 31
|
||||||
|
const dateStr = lastDay.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// 8. Create journal entry via existing service
|
||||||
|
const je = await this.journalEntriesService.create(
|
||||||
|
{
|
||||||
|
entryDate: dateStr,
|
||||||
|
description: `Monthly actuals for ${monthLabel} ${year}`,
|
||||||
|
referenceNumber: `ACTUAL-${year}-${String(month).padStart(2, '0')}`,
|
||||||
|
entryType: 'monthly_actual',
|
||||||
|
lines: jeLines,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. Auto-post the entry
|
||||||
|
await this.journalEntriesService.post(je.id, userId);
|
||||||
|
|
||||||
|
// 10. Set is_reconciled flag
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE journal_entries SET is_reconciled = true WHERE id = $1`,
|
||||||
|
[je.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Saved ${filteredLines.length} actuals for ${monthLabel} ${year}`,
|
||||||
|
journal_entry_id: je.id,
|
||||||
|
lines_count: filteredLines.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { Organization } from './entities/organization.entity';
|
|||||||
import { UserOrganization } from './entities/user-organization.entity';
|
import { UserOrganization } from './entities/user-organization.entity';
|
||||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsService {
|
export class OrganizationsService {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
|||||||
import { AdminPage } from './pages/admin/AdminPage';
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
|
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -118,6 +119,7 @@ export function App() {
|
|||||||
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
||||||
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||||
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
||||||
|
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />
|
||||||
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
|
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
|
||||||
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
|
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
|
||||||
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
|
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
|
||||||
|
|||||||
191
frontend/src/components/attachments/AttachmentPanel.tsx
Normal file
191
frontend/src/components/attachments/AttachmentPanel.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Group, Text, Button, ActionIcon, Stack, Badge, Tooltip, Paper,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconUpload, IconDownload, IconTrash, IconFile, IconPhoto, IconFileSpreadsheet } from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string;
|
||||||
|
journal_entry_id: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
file_size: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentPanelProps {
|
||||||
|
journalEntryId: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(mimeType: string) {
|
||||||
|
if (mimeType.startsWith('image/')) return IconPhoto;
|
||||||
|
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return IconFileSpreadsheet;
|
||||||
|
return IconFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPanel({ journalEntryId, readOnly }: AttachmentPanelProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: attachments = [], isLoading } = useQuery<Attachment[]>({
|
||||||
|
queryKey: ['attachments', journalEntryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/attachments/journal-entry/${journalEntryId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!journalEntryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return api.post(`/attachments/journal-entry/${journalEntryId}`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['attachments', journalEntryId] });
|
||||||
|
notifications.show({ message: 'File uploaded', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Upload failed',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['attachments', journalEntryId] });
|
||||||
|
notifications.show({ message: 'Attachment deleted', color: 'yellow' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Delete failed',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownload = async (attachment: Attachment) => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/attachments/${attachment.id}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const blob = new Blob([response.data], { type: attachment.mime_type });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = attachment.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
notifications.show({ message: 'Download failed', color: 'red' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploadMutation.mutate(file);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!journalEntryId) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Save actuals first to attach receipts and invoices.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconUpload size={14} />}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
loading={uploadMutation.isPending}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp,.gif,.xls,.xlsx"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <Text size="sm" c="dimmed">Loading attachments...</Text>}
|
||||||
|
|
||||||
|
{attachments.length === 0 && !isLoading && (
|
||||||
|
<Text size="sm" c="dimmed">No attachments yet.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachments.map((att) => {
|
||||||
|
const FileIcon = getFileIcon(att.mime_type);
|
||||||
|
return (
|
||||||
|
<Paper key={att.id} p="xs" withBorder>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
|
<FileIcon size={18} />
|
||||||
|
<div style={{ overflow: 'hidden' }}>
|
||||||
|
<Text size="sm" fw={500} truncate="end">{att.filename}</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{formatFileSize(att.file_size)}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(att.created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<ActionIcon variant="subtle" size="sm" onClick={() => handleDownload(att)}>
|
||||||
|
<IconDownload size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
{!readOnly && (
|
||||||
|
<Tooltip label="Delete">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteMutation.mutate(att.id)}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconCrown,
|
IconCrown,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
IconChartAreaLine,
|
IconChartAreaLine,
|
||||||
|
IconClipboardCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const navSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
{ label: 'Accounts', icon: IconListDetails, path: '/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: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
317
frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx
Normal file
317
frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
|
Select, Loader, Center, Card, SimpleGrid, Badge, Alert,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
|
interface ActualLine {
|
||||||
|
account_id: string;
|
||||||
|
account_number: string;
|
||||||
|
account_name: string;
|
||||||
|
account_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
budget_amount: number;
|
||||||
|
actual_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActualsGrid {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
month_label: string;
|
||||||
|
existing_journal_entry_id: string | null;
|
||||||
|
lines: ActualLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthOptions = [
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getDefaultMonth(): { year: string; month: string } {
|
||||||
|
const now = new Date();
|
||||||
|
// Default to previous completed month
|
||||||
|
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
return {
|
||||||
|
year: String(prev.getFullYear()),
|
||||||
|
month: String(prev.getMonth() + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
(v || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
export function MonthlyActualsPage() {
|
||||||
|
const defaults = getDefaultMonth();
|
||||||
|
const [year, setYear] = useState(defaults.year);
|
||||||
|
const [month, setMonth] = useState(defaults.month);
|
||||||
|
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||||
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
|
return { value: String(y), label: String(y) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: grid, isLoading } = useQuery<ActualsGrid>({
|
||||||
|
queryKey: ['monthly-actuals', year, month],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
||||||
|
setEditedAmounts({});
|
||||||
|
setSavedJEId(data.existing_journal_entry_id || null);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const lines = (grid?.lines || [])
|
||||||
|
.map((line) => ({
|
||||||
|
accountId: line.account_id,
|
||||||
|
amount: editedAmounts[line.account_id] !== undefined
|
||||||
|
? editedAmounts[line.account_id]
|
||||||
|
: line.actual_amount,
|
||||||
|
}))
|
||||||
|
.filter((l) => l.amount !== 0);
|
||||||
|
const { data } = await api.post(`/monthly-actuals/${year}/${month}`, { lines });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['monthly-actuals'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
||||||
|
setSavedJEId(data.journal_entry_id);
|
||||||
|
notifications.show({
|
||||||
|
message: data.message || 'Actuals saved and reconciled',
|
||||||
|
color: 'green',
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Failed to save actuals',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAmount = (line: ActualLine): number => {
|
||||||
|
return editedAmounts[line.account_id] !== undefined
|
||||||
|
? editedAmounts[line.account_id]
|
||||||
|
: line.actual_amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAmount = (accountId: string, value: number) => {
|
||||||
|
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = grid?.lines || [];
|
||||||
|
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
||||||
|
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const incomeBudget = incomeLines.reduce((s, l) => s + l.budget_amount, 0);
|
||||||
|
const incomeActual = incomeLines.reduce((s, l) => s + getAmount(l), 0);
|
||||||
|
const expenseBudget = expenseLines.reduce((s, l) => s + l.budget_amount, 0);
|
||||||
|
const expenseActual = expenseLines.reduce((s, l) => s + getAmount(l), 0);
|
||||||
|
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
||||||
|
}, [lines, editedAmounts]);
|
||||||
|
|
||||||
|
const hasChanges = Object.keys(editedAmounts).length > 0;
|
||||||
|
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
const renderSection = (
|
||||||
|
title: string,
|
||||||
|
sectionLines: ActualLine[],
|
||||||
|
bgColor: string,
|
||||||
|
budgetTotal: number,
|
||||||
|
actualTotal: number,
|
||||||
|
) => {
|
||||||
|
if (sectionLines.length === 0) return null;
|
||||||
|
const variance = actualTotal - budgetTotal;
|
||||||
|
const isExpense = title === 'Expenses';
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Table.Tr key={`header-${title}`} style={{ background: bgColor }}>
|
||||||
|
<Table.Td
|
||||||
|
colSpan={2}
|
||||||
|
fw={700}
|
||||||
|
style={{ position: 'sticky', left: 0, background: bgColor, zIndex: 2 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
||||||
|
<Table.Td />
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace"
|
||||||
|
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
||||||
|
>
|
||||||
|
{variance > 0 ? '+' : ''}{fmt(variance)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
...sectionLines.map((line) => {
|
||||||
|
const amount = getAmount(line);
|
||||||
|
const lineVariance = amount - line.budget_amount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr key={line.account_id}>
|
||||||
|
<Table.Td
|
||||||
|
style={{
|
||||||
|
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
||||||
|
borderRight: '1px solid #e9ecef',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
|
||||||
|
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
||||||
|
{fmt(line.budget_amount)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td p={2} style={{ minWidth: 130 }}>
|
||||||
|
<NumberInput
|
||||||
|
value={amount}
|
||||||
|
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
||||||
|
size="xs"
|
||||||
|
hideControls
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td
|
||||||
|
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
||||||
|
c={lineVariance === 0 ? 'gray' : (isExpense ? (lineVariance > 0 ? 'red' : 'green') : (lineVariance > 0 ? 'green' : 'red'))}
|
||||||
|
>
|
||||||
|
{lineVariance > 0 ? '+' : ''}{fmt(lineVariance)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<IconCalendarMonth size={28} />
|
||||||
|
<Title order={2}>Monthly Actuals</Title>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
|
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||||
|
<Button
|
||||||
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
disabled={lines.length === 0}
|
||||||
|
>
|
||||||
|
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Income Budget</Text>
|
||||||
|
<Text fw={700} size="lg">{fmt(totals.incomeBudget)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Actual: {fmt(totals.incomeActual)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Expense Budget</Text>
|
||||||
|
<Text fw={700} size="lg">{fmt(totals.expenseBudget)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Actual: {fmt(totals.expenseActual)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Budget</Text>
|
||||||
|
<Text fw={700} size="lg" c={totals.incomeBudget - totals.expenseBudget >= 0 ? 'green' : 'red'}>
|
||||||
|
{fmt(totals.incomeBudget - totals.expenseBudget)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Actual</Text>
|
||||||
|
<Text fw={700} size="lg" c={totals.incomeActual - totals.expenseActual >= 0 ? 'green' : 'red'}>
|
||||||
|
{fmt(totals.incomeActual - totals.expenseActual)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
No income or expense accounts found for {monthLabel} {year}. Import a budget first to create accounts.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{savedJEId && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Text size="sm">
|
||||||
|
Actuals for {monthLabel} {year} have been reconciled.
|
||||||
|
Journal entry created and auto-posted.
|
||||||
|
</Text>
|
||||||
|
<Badge color="green" variant="light">Reconciled</Badge>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||||
|
<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 ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||||
|
<Table.Th ta="right" style={{ minWidth: 130 }}>Actual</Table.Th>
|
||||||
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Variance</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
||||||
|
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attachment panel - show when we have a saved journal entry for this month */}
|
||||||
|
{savedJEId && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="sm">Attachments (Receipts & Invoices)</Text>
|
||||||
|
<AttachmentPanel journalEntryId={savedJEId} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,8 +27,25 @@ interface BudgetVsActualData {
|
|||||||
total_expense_actual: number;
|
total_expense_actual: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const monthFilterOptions = [
|
||||||
|
{ value: '', label: 'Full Year' },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
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 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;
|
||||||
@@ -36,9 +53,10 @@ export function BudgetVsActualPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<BudgetVsActualData>({
|
const { data, isLoading } = useQuery<BudgetVsActualData>({
|
||||||
queryKey: ['budget-vs-actual', year],
|
queryKey: ['budget-vs-actual', year, month],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/budgets/${year}/vs-actual`);
|
const params = month ? `?month=${month}` : '';
|
||||||
|
const { data } = await api.get(`/budgets/${year}/vs-actual${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -127,7 +145,17 @@ export function BudgetVsActualPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Budget vs. Actual</Title>
|
<Title order={2}>Budget vs. Actual</Title>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Group>
|
||||||
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
|
<Select
|
||||||
|
data={monthFilterOptions}
|
||||||
|
value={month}
|
||||||
|
onChange={(v) => setMonth(v || '')}
|
||||||
|
w={150}
|
||||||
|
placeholder="Month"
|
||||||
|
clearable={false}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ 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, IconEye, IconCheck, IconX, IconTrash } from '@tabler/icons-react';
|
import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from '@tabler/icons-react';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ interface JournalEntry {
|
|||||||
entry_type: string;
|
entry_type: string;
|
||||||
is_posted: boolean;
|
is_posted: boolean;
|
||||||
is_void: boolean;
|
is_void: boolean;
|
||||||
|
is_reconciled?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
lines?: JournalEntryLine[];
|
lines?: JournalEntryLine[];
|
||||||
total_debit?: string;
|
total_debit?: string;
|
||||||
@@ -190,13 +192,22 @@ export function TransactionsPage() {
|
|||||||
<Table.Td ta="right" ff="monospace">{fmt(e.total_debit || '0')}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(e.total_debit || '0')}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(e.total_credit || '0')}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(e.total_credit || '0')}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{e.is_void ? (
|
<Group gap={4}>
|
||||||
<Badge color="red" variant="light" size="sm">Void</Badge>
|
{e.is_void ? (
|
||||||
) : e.is_posted ? (
|
<Badge color="red" variant="light" size="sm">Void</Badge>
|
||||||
<Badge color="green" variant="light" size="sm">Posted</Badge>
|
) : e.is_posted ? (
|
||||||
) : (
|
<Badge color="green" variant="light" size="sm">Posted</Badge>
|
||||||
<Badge color="yellow" variant="light" size="sm">Draft</Badge>
|
) : (
|
||||||
)}
|
<Badge color="yellow" variant="light" size="sm">Draft</Badge>
|
||||||
|
)}
|
||||||
|
{e.is_reconciled && (
|
||||||
|
<Tooltip label="Reconciled">
|
||||||
|
<Badge color="teal" variant="light" size="sm" leftSection={<IconShieldCheck size={12} />}>
|
||||||
|
Reconciled
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -353,6 +364,11 @@ export function TransactionsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Text><strong>Description:</strong> {viewEntry.description}</Text>
|
<Text><strong>Description:</strong> {viewEntry.description}</Text>
|
||||||
{viewEntry.reference_number && <Text><strong>Ref #:</strong> {viewEntry.reference_number}</Text>}
|
{viewEntry.reference_number && <Text><strong>Ref #:</strong> {viewEntry.reference_number}</Text>}
|
||||||
|
{viewEntry.is_reconciled && (
|
||||||
|
<Badge color="teal" variant="light" leftSection={<IconShieldCheck size={14} />}>
|
||||||
|
Reconciled
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Table>
|
<Table>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -373,6 +389,10 @@ export function TransactionsPage() {
|
|||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<Text fw={500} mt="md">Attachments</Text>
|
||||||
|
<AttachmentPanel journalEntryId={viewEntry.id} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user