diff --git a/backend/src/cost/__test__/cashflow-cost.service.spec.ts b/backend/src/cost/__test__/cashflow-cost.service.spec.ts index fee3fe1d..4a043953 100644 --- a/backend/src/cost/__test__/cashflow-cost.service.spec.ts +++ b/backend/src/cost/__test__/cashflow-cost.service.spec.ts @@ -173,6 +173,7 @@ describe('CostService', () => { type: CostType.MealsFood, frequency: Frequency.Yearly, date: '2026-03-22' as TDateISO, + interval: 12, }; const result = await service.createCost(payload); @@ -182,6 +183,7 @@ describe('CostService', () => { amount: 200, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }); expect(mockPut).toHaveBeenCalledWith({ @@ -191,6 +193,7 @@ describe('CostService', () => { amount: 200, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }, ConditionExpression: 'attribute_not_exists(#name)', @@ -207,6 +210,7 @@ describe('CostService', () => { amount: 0, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -216,6 +220,7 @@ describe('CostService', () => { amount: 0, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('amount must be a finite positive number'); @@ -228,6 +233,7 @@ describe('CostService', () => { amount: 100, type: 'INVALID' as unknown as CostType, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -240,6 +246,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: 'INVALID' as unknown as Frequency, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -252,6 +259,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -261,6 +269,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('name must be a non-empty string'); @@ -275,6 +284,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(ConflictException); @@ -284,6 +294,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Cost with name Food already exists'); @@ -298,6 +309,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(InternalServerErrorException); @@ -312,6 +324,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(InternalServerErrorException); @@ -321,6 +334,7 @@ describe('CostService', () => { amount: 100, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Failed to create cost'); @@ -335,6 +349,7 @@ describe('CostService', () => { amount: 200, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }, }); @@ -347,6 +362,7 @@ describe('CostService', () => { type: CostType.Services, frequency: Frequency.OneTime, date: '2026-03-22' as TDateISO, + interval: 0, }; mockPutPromise.mockResolvedValue({}); @@ -356,6 +372,7 @@ describe('CostService', () => { type: CostType.Services, frequency: Frequency.OneTime, date: '2026-03-22' as TDateISO, + interval: 0, }); expect(result).toEqual(updatedItem); @@ -370,6 +387,7 @@ describe('CostService', () => { amount: 300, type: CostType.Services, frequency: Frequency.OneTime, + interval: 0, date: '2026-03-22', }, ConditionExpression: 'attribute_exists(#name)', @@ -385,6 +403,7 @@ describe('CostService', () => { amount: 200, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }); @@ -393,6 +412,7 @@ describe('CostService', () => { amount: 200, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }); expect(mockPut).not.toHaveBeenCalled(); @@ -406,6 +426,7 @@ describe('CostService', () => { amount: Number.NaN, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -418,6 +439,7 @@ describe('CostService', () => { amount: 250, type: 'INVALID' as unknown as CostType, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -430,6 +452,33 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: 'INVALID' as unknown as Frequency, + interval: 12, + date: '2026-03-22' as TDateISO, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid interval', async () => { + await expect( + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + frequency: Frequency.Yearly, + interval: 'INVALID' as unknown as number, + date: '2026-03-22' as TDateISO, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid interval', async () => { + await expect( + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + frequency: Frequency.Yearly, + interval: 'INVALID' as unknown as number, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -442,6 +491,7 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: 'not-a-date' as unknown as TDateISO, }), ).rejects.toThrow(BadRequestException); @@ -451,6 +501,7 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: 'not-a-date' as unknown as TDateISO, }), ).rejects.toThrow('date must be a valid ISO 8601 format string'); @@ -465,6 +516,7 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(NotFoundException); @@ -474,6 +526,7 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Cost with name Food not found'); @@ -489,6 +542,7 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(InternalServerErrorException); @@ -498,6 +552,7 @@ describe('CostService', () => { amount: 250, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Failed to update cost Food'); @@ -513,6 +568,7 @@ describe('CostService', () => { name: 'Meals', amount: 300, type: CostType.MealsFood, + interval: 12, frequency: Frequency.Yearly, date: '2026-03-22' as TDateISO, }); @@ -522,6 +578,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }); expect(mockGet).toHaveBeenCalledWith({ @@ -539,6 +596,7 @@ describe('CostService', () => { type: CostType.MealsFood, frequency: Frequency.Yearly, date: '2026-03-22', + interval: 12, }, ConditionExpression: 'attribute_not_exists(#name)', ExpressionAttributeNames: { @@ -571,6 +629,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }); @@ -579,6 +638,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }); }); @@ -592,6 +652,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(NotFoundException); @@ -601,6 +662,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Cost with name Food not found'); @@ -622,6 +684,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(ConflictException); @@ -631,6 +694,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Cost with name Meals already exists'); @@ -652,6 +716,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(NotFoundException); @@ -661,6 +726,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Cost with name Food not found'); @@ -678,6 +744,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(InternalServerErrorException); @@ -687,6 +754,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow('Failed to update cost Food'); @@ -701,6 +769,7 @@ describe('CostService', () => { amount: 200, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(InternalServerErrorException); diff --git a/backend/src/cost/cashflow-cost.service.ts b/backend/src/cost/cashflow-cost.service.ts index 345d94a9..fe919730 100644 --- a/backend/src/cost/cashflow-cost.service.ts +++ b/backend/src/cost/cashflow-cost.service.ts @@ -46,6 +46,12 @@ export class CostService { } } + private validateInterval(interval: number) { + if (!Number.isFinite(interval) || interval < 0 || interval === null || interval > 36) { + throw new BadRequestException("interval must be a finite number between 0 and 36"); + } + } + private validateName(name: string) { if (name === null || name.trim().length === 0) { throw new BadRequestException("name must be a non-empty string"); @@ -143,6 +149,7 @@ export class CostService { this.validateAmount(cost.amount); this.validateCostType(cost.type); this.validateFrequency(cost.frequency); + this.validateInterval(cost.interval); this.validateName(cost.name); const normalizedName = cost.name.trim(); @@ -206,6 +213,7 @@ export class CostService { this.validateAmount(updates.amount); this.validateCostType(updates.type); this.validateFrequency(updates.frequency); + this.validateInterval(updates.interval); if (updates.name !== undefined) { this.validateName(updates.name); @@ -238,6 +246,7 @@ export class CostService { existingCost.amount === updates.amount && existingCost.type === updates.type && existingCost.frequency === updates.frequency && + existingCost.interval === updates.interval && datesAreEqual; if (isUnchanged) { diff --git a/backend/src/cost/types/cost.types.ts b/backend/src/cost/types/cost.types.ts index 8ed23f90..c7b112a8 100644 --- a/backend/src/cost/types/cost.types.ts +++ b/backend/src/cost/types/cost.types.ts @@ -23,6 +23,11 @@ export class CashflowCostDTO implements CashflowCost { @Min(0.01) amount!: number; + @ApiProperty({ description: 'The interval for the cost item', example: 12 }) + @IsNumber({ allowInfinity: false, allowNaN: false }) + @Min(0) + interval!: number; + @ApiProperty({ description: 'The type of cost', enum: CostType, example: CostType.Salary }) @IsEnum(CostType) type!: CostType; diff --git a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts index e50b32b5..16cb6044 100644 --- a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts +++ b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts @@ -1,18 +1,31 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; -import { RevenueService } from '../cashflow-revenue.service'; -import { RevenueType } from '../../../../middle-layer/types/RevenueType'; -import { CashflowRevenue } from '../../../../middle-layer/types/CashflowRevenue'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vitest'; +import { Test, TestingModule } from "@nestjs/testing"; +import { + BadRequestException, + InternalServerErrorException, +} from "@nestjs/common"; +import { RevenueService } from "../cashflow-revenue.service"; +import { RevenueType } from "../../../../middle-layer/types/RevenueType"; +import { CashflowRevenue } from "../../../../middle-layer/types/CashflowRevenue"; +import { Grant } from "../../../../middle-layer/types/Grant"; +import { Status } from "../../../../middle-layer/types/Status"; +import { + describe, + it, + expect, + beforeEach, + afterEach, + beforeAll, + vi, +} from "vitest"; // ─── Mock function declarations ─────────────────────────────────────────────── -const mockGet = vi.fn(); -const mockScan = vi.fn(); -const mockPut = vi.fn(); +const mockGet = vi.fn(); +const mockScan = vi.fn(); +const mockPut = vi.fn(); const mockDelete = vi.fn(); // ─── AWS SDK mock ───────────────────────────────────────────────────────────── -vi.mock('aws-sdk', () => { +vi.mock("aws-sdk", () => { const documentClientFactory = vi.fn(function () { return { scan: mockScan, get: mockGet, put: mockPut, delete: mockDelete }; }); @@ -21,46 +34,114 @@ vi.mock('aws-sdk', () => { }); // ─── Helpers ────────────────────────────────────────────────────────────────── -const resolved = (value: unknown) => ({ promise: vi.fn().mockResolvedValue(value) }); -const rejected = (error: unknown) => ({ promise: vi.fn().mockRejectedValue(error) }); -const awsError = (code: string, message = 'AWS error') => ({ code, message, requestId: 'mock-request-id' }); +const resolved = (value: unknown) => ({ + promise: vi.fn().mockResolvedValue(value), +}); +const rejected = (error: unknown) => ({ + promise: vi.fn().mockRejectedValue(error), +}); +const awsError = (code: string, message = "AWS error") => ({ + code, + message, + requestId: "mock-request-id", +}); // ─── Mock data ──────────────────────────────────────────────────────────────── // IMPORTANT: installment dates must be ISO strings — the validator rejects Date objects const mockRevenue: CashflowRevenue = { - name: 'Test Revenue', + name: "Test Revenue", amount: 1000, type: RevenueType.Donation, installments: [ - { amount: 500, date: '2024-01-01' as any }, - { amount: 500, date: '2024-06-01' as any }, + { amount: 500, date: "2024-01-01" as any }, + { amount: 500, date: "2024-06-01" as any }, ], }; const mockDatabase: CashflowRevenue[] = [ - { name: 'Revenue One', amount: 1000, type: RevenueType.Donation, installments: [{ amount: 1000, date: '2024-01-01' as any }] }, - { name: 'Revenue Two', amount: 2000, type: RevenueType.Grants, installments: [{ amount: 2000, date: '2024-02-01' as any }] }, - { name: 'Revenue Three', amount: 3000, type: RevenueType.Sponsorship, installments: [{ amount: 3000, date: '2024-03-01' as any }] }, + { + name: "Revenue One", + amount: 1000, + type: RevenueType.Donation, + installments: [{ amount: 1000, date: "2024-01-01" as any }], + }, + { + name: "Revenue Two", + amount: 2000, + type: RevenueType.Grants, + installments: [{ amount: 2000, date: "2024-02-01" as any }], + }, + { + name: "Revenue Three", + amount: 3000, + type: RevenueType.Sponsorship, + installments: [{ amount: 3000, date: "2024-03-01" as any }], + }, ]; +const activeGrant: Grant = { + grantId: 1, + organization: "Active Grant Org", + does_bcan_qualify: true, + status: Status.Active, + amount: 4000, + grant_start_date: "2024-07-01" as any, + application_deadline: "2024-06-01" as any, + timeline: 1, + estimated_completion_time: 8, + bcan_poc: { POC_name: "bcan", POC_email: "bcan@gmail.com" } as any, + attachments: [] as any, + isRestricted: false, +} as Grant; + +const inactiveGrant: Grant = { + ...activeGrant, + grantId: 2, + organization: "Inactive Grant Org", + status: Status.Inactive, + grant_start_date: "2024-08-01" as any, +} as Grant; + // ─── Test suite ─────────────────────────────────────────────────────────────── -describe('RevenueService', () => { +describe("RevenueService", () => { let service: RevenueService; + let revenueItems: CashflowRevenue[]; + let grantItems: Grant[]; beforeAll(() => { - process.env.CASHFLOW_REVENUE_TABLE_NAME = 'test-revenue-table'; + process.env.CASHFLOW_REVENUE_TABLE_NAME = "test-revenue-table"; + process.env.DYNAMODB_GRANT_TABLE_NAME = "test-grant-table"; }); // Guarantee env var is restored after every test, even if the test throws afterEach(() => { - process.env.CASHFLOW_REVENUE_TABLE_NAME = 'test-revenue-table'; + process.env.CASHFLOW_REVENUE_TABLE_NAME = "test-revenue-table"; + process.env.DYNAMODB_GRANT_TABLE_NAME = "test-grant-table"; }); beforeEach(async () => { vi.clearAllMocks(); - mockScan.mockReturnValue(resolved({})); - mockGet.mockReturnValue(resolved({})); + revenueItems = mockDatabase; + grantItems = []; + + mockScan.mockImplementation((params) => { + if (params.TableName === "test-revenue-table") { + return resolved({ Items: revenueItems }); + } + + if (params.TableName === "test-grant-table") { + return resolved({ Items: grantItems }); + } + + return resolved({}); + }); + mockGet.mockImplementation((params) => { + if (params.Key.name === "Test Revenue") { + return resolved({ Item: mockRevenue }); + } + return resolved({}); + }); mockPut.mockReturnValue(resolved({})); mockDelete.mockReturnValue(resolved({})); @@ -73,450 +154,679 @@ describe('RevenueService', () => { // ─── getAllRevenue ─────────────────────────────────────────────────────────── - describe('getAllRevenue', () => { - it('should return all revenue items', async () => { + describe("getAllRevenue", () => { + it("should return all revenue items", async () => { mockScan.mockReturnValue(resolved({ Items: mockDatabase })); const result = await service.getAllRevenue(); expect(result).toHaveLength(3); - expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-revenue-table' }); + expect(mockScan).toHaveBeenCalledWith({ + TableName: "test-revenue-table", + }); + }); + + it("should only scan the revenue table", async () => { + grantItems = [activeGrant, inactiveGrant]; + + await service.getAllRevenue(); + + expect(mockScan).toHaveBeenCalledTimes(1); + expect(mockScan).toHaveBeenCalledWith({ + TableName: "test-revenue-table", + }); }); - it('should return an empty array when no items exist', async () => { + it("should return an empty array when no items exist", async () => { mockScan.mockReturnValue(resolved({ Items: [] })); const result = await service.getAllRevenue(); expect(result).toEqual([]); }); - it('should throw InternalServerErrorException when response is null', async () => { + it("should throw InternalServerErrorException when response is null", async () => { mockScan.mockReturnValue(resolved(null)); - await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + await expect(service.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException when Items is undefined', async () => { + it("should throw InternalServerErrorException when Items is undefined", async () => { mockScan.mockReturnValue(resolved({})); - await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + await expect(service.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException when table name env var is not set', async () => { + it("should throw InternalServerErrorException when table name env var is not set", async () => { delete process.env.CASHFLOW_REVENUE_TABLE_NAME; - const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const module = await Test.createTestingModule({ + providers: [RevenueService], + }).compile(); const serviceNoTable = module.get(RevenueService); - await expect(serviceNoTable.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + await expect(serviceNoTable.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on generic unexpected error', async () => { - mockScan.mockReturnValue(rejected(new Error('Unexpected error'))); - await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on generic unexpected error", async () => { + mockScan.mockReturnValue(rejected(new Error("Unexpected error"))); + await expect(service.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on ResourceNotFoundException', async () => { - mockScan.mockReturnValue(rejected(awsError('ResourceNotFoundException', 'Table not found'))); - await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on ResourceNotFoundException", async () => { + mockScan.mockReturnValue( + rejected(awsError("ResourceNotFoundException", "Table not found")), + ); + await expect(service.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on ThrottlingException', async () => { - mockScan.mockReturnValue(rejected(awsError('ThrottlingException'))); - await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on ThrottlingException", async () => { + mockScan.mockReturnValue(rejected(awsError("ThrottlingException"))); + await expect(service.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on ProvisionedThroughputExceededException', async () => { - mockScan.mockReturnValue(rejected(awsError('ProvisionedThroughputExceededException'))); - await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on ProvisionedThroughputExceededException", async () => { + mockScan.mockReturnValue( + rejected(awsError("ProvisionedThroughputExceededException")), + ); + await expect(service.getAllRevenue()).rejects.toThrow( + InternalServerErrorException, + ); }); }); // ─── createRevenue ────────────────────────────────────────────────────────── - describe('createRevenue', () => { - it('should create and return a revenue item', async () => { + describe("createRevenue", () => { + it("should create and return a revenue item", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); mockPut.mockReturnValue(resolved({})); const result = await service.createRevenue(mockRevenue); expect(result).toEqual({ ...mockRevenue, name: mockRevenue.name.trim() }); - expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ - TableName: 'test-revenue-table', - ConditionExpression: 'attribute_not_exists(#name)', - })); + expect(mockPut).toHaveBeenCalledWith( + expect.objectContaining({ + TableName: "test-revenue-table", + ConditionExpression: "attribute_not_exists(#name)", + }), + ); }); - it('should trim the name before saving', async () => { + it("should trim the name before saving", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); mockPut.mockReturnValue(resolved({})); - const result = await service.createRevenue({ ...mockRevenue, name: ' Padded Name ' }); - expect(result.name).toBe('Padded Name'); + const result = await service.createRevenue({ + ...mockRevenue, + name: " Padded Name ", + }); + expect(result.name).toBe("Padded Name"); }); - it('should use trimmed name as the DynamoDB key', async () => { + it("should use trimmed name as the DynamoDB key", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); mockPut.mockReturnValue(resolved({})); - await service.createRevenue({ ...mockRevenue, name: ' Padded Name ' }); - expect(mockGet).toHaveBeenCalledWith(expect.objectContaining({ - Key: { name: 'Padded Name' }, - })); + await service.createRevenue({ ...mockRevenue, name: " Padded Name " }); + expect(mockGet).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { name: "Padded Name" }, + }), + ); }); // Duplicate detection - it('should throw BadRequestException when item with same name already exists', async () => { + it("should throw BadRequestException when item with same name already exists", async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); - await expect(service.createRevenue(mockRevenue)).rejects.toThrow(BadRequestException); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow( + BadRequestException, + ); expect(mockPut).not.toHaveBeenCalled(); }); - it('should throw BadRequestException on ConditionalCheckFailedException race condition', async () => { + it("should throw BadRequestException on ConditionalCheckFailedException race condition", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); - mockPut.mockReturnValue(rejected(awsError('ConditionalCheckFailedException', 'Item already exists'))); - await expect(service.createRevenue(mockRevenue)).rejects.toThrow(BadRequestException); + mockPut.mockReturnValue( + rejected( + awsError("ConditionalCheckFailedException", "Item already exists"), + ), + ); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow( + BadRequestException, + ); }); // Validation — body - it('should throw BadRequestException when revenue body is null', async () => { - await expect(service.createRevenue(null as any)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when revenue body is null", async () => { + await expect(service.createRevenue(null as any)).rejects.toThrow( + BadRequestException, + ); }); // Validation — amount - it('should throw BadRequestException when amount is null', async () => { - await expect(service.createRevenue({ ...mockRevenue, amount: null as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is null", async () => { + await expect( + service.createRevenue({ ...mockRevenue, amount: null as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when amount is undefined', async () => { - await expect(service.createRevenue({ ...mockRevenue, amount: undefined as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is undefined", async () => { + await expect( + service.createRevenue({ ...mockRevenue, amount: undefined as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when amount is zero', async () => { - await expect(service.createRevenue({ ...mockRevenue, amount: 0 })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is zero", async () => { + await expect( + service.createRevenue({ ...mockRevenue, amount: 0 }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when amount is negative', async () => { - await expect(service.createRevenue({ ...mockRevenue, amount: -100 })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is negative", async () => { + await expect( + service.createRevenue({ ...mockRevenue, amount: -100 }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when amount is Infinity', async () => { - await expect(service.createRevenue({ ...mockRevenue, amount: Infinity })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is Infinity", async () => { + await expect( + service.createRevenue({ ...mockRevenue, amount: Infinity }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when amount is NaN', async () => { - await expect(service.createRevenue({ ...mockRevenue, amount: NaN })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is NaN", async () => { + await expect( + service.createRevenue({ ...mockRevenue, amount: NaN }), + ).rejects.toThrow(BadRequestException); }); // Validation — type - it('should throw BadRequestException when type is invalid', async () => { - await expect(service.createRevenue({ ...mockRevenue, type: 'InvalidType' as RevenueType })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when type is invalid", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + type: "InvalidType" as RevenueType, + }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when type is null', async () => { - await expect(service.createRevenue({ ...mockRevenue, type: null as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when type is null", async () => { + await expect( + service.createRevenue({ ...mockRevenue, type: null as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when type is undefined', async () => { - await expect(service.createRevenue({ ...mockRevenue, type: undefined as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when type is undefined", async () => { + await expect( + service.createRevenue({ ...mockRevenue, type: undefined as any }), + ).rejects.toThrow(BadRequestException); }); // Validation — name - it('should throw BadRequestException when name is null', async () => { - await expect(service.createRevenue({ ...mockRevenue, name: null as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is null", async () => { + await expect( + service.createRevenue({ ...mockRevenue, name: null as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when name is undefined', async () => { - await expect(service.createRevenue({ ...mockRevenue, name: undefined as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is undefined", async () => { + await expect( + service.createRevenue({ ...mockRevenue, name: undefined as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when name is an empty string', async () => { - await expect(service.createRevenue({ ...mockRevenue, name: '' })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is an empty string", async () => { + await expect( + service.createRevenue({ ...mockRevenue, name: "" }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when name is only whitespace', async () => { - await expect(service.createRevenue({ ...mockRevenue, name: ' ' })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is only whitespace", async () => { + await expect( + service.createRevenue({ ...mockRevenue, name: " " }), + ).rejects.toThrow(BadRequestException); }); // Validation — installments - it('should throw BadRequestException when installments is null', async () => { - await expect(service.createRevenue({ ...mockRevenue, installments: null as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installments is null", async () => { + await expect( + service.createRevenue({ ...mockRevenue, installments: null as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when installments is undefined', async () => { - await expect(service.createRevenue({ ...mockRevenue, installments: undefined as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installments is undefined", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + installments: undefined as any, + }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when installments is not an array', async () => { - await expect(service.createRevenue({ ...mockRevenue, installments: 'bad' as any })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installments is not an array", async () => { + await expect( + service.createRevenue({ ...mockRevenue, installments: "bad" as any }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when an installment amount is negative', async () => { - await expect(service.createRevenue({ - ...mockRevenue, - installments: [{ amount: -50, date: '2024-01-01' as any }], - })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when an installment amount is negative", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + installments: [{ amount: -50, date: "2024-01-01" as any }], + }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when an installment amount is zero', async () => { - await expect(service.createRevenue({ - ...mockRevenue, - installments: [{ amount: 0, date: '2024-01-01' as any }], - })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when an installment amount is zero", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + installments: [{ amount: 0, date: "2024-01-01" as any }], + }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when installment date is null', async () => { - await expect(service.createRevenue({ - ...mockRevenue, - installments: [{ amount: 1000, date: null as any }], - })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installment date is null", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + installments: [{ amount: 1000, date: null as any }], + }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when installment date is an invalid format', async () => { - await expect(service.createRevenue({ - ...mockRevenue, - installments: [{ amount: 1000, date: 'not-a-date' as any }], - })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installment date is an invalid format", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + installments: [{ amount: 1000, date: "not-a-date" as any }], + }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when installments do not sum to the total amount', async () => { - await expect(service.createRevenue({ - ...mockRevenue, - amount: 1000, - installments: [ - { amount: 400, date: '2024-01-01' as any }, - { amount: 400, date: '2024-06-01' as any }, - ], - })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installments do not sum to the total amount", async () => { + await expect( + service.createRevenue({ + ...mockRevenue, + amount: 1000, + installments: [ + { amount: 400, date: "2024-01-01" as any }, + { amount: 400, date: "2024-06-01" as any }, + ], + }), + ).rejects.toThrow(BadRequestException); }); // AWS errors - it('should throw InternalServerErrorException when get check throws an AWS error', async () => { - mockGet.mockReturnValue(rejected(awsError('ResourceNotFoundException'))); - await expect(service.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException when get check throws an AWS error", async () => { + mockGet.mockReturnValue(rejected(awsError("ResourceNotFoundException"))); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on ThrottlingException during put', async () => { + it("should throw InternalServerErrorException on ThrottlingException during put", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); - mockPut.mockReturnValue(rejected(awsError('ThrottlingException'))); - await expect(service.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + mockPut.mockReturnValue(rejected(awsError("ThrottlingException"))); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on generic unexpected error during put', async () => { + it("should throw InternalServerErrorException on generic unexpected error during put", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); - mockPut.mockReturnValue(rejected(new Error('Unexpected error'))); - await expect(service.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + mockPut.mockReturnValue(rejected(new Error("Unexpected error"))); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException when table name is not set', async () => { + it("should throw InternalServerErrorException when table name is not set", async () => { delete process.env.CASHFLOW_REVENUE_TABLE_NAME; - const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const module = await Test.createTestingModule({ + providers: [RevenueService], + }).compile(); const serviceNoTable = module.get(RevenueService); - await expect(serviceNoTable.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + await expect(serviceNoTable.createRevenue(mockRevenue)).rejects.toThrow( + InternalServerErrorException, + ); }); }); // ─── updateRevenue ────────────────────────────────────────────────────────── - describe('updateRevenue', () => { - it('should update and return the revenue item when name is unchanged', async () => { + describe("updateRevenue", () => { + it("should update and return the revenue item when name is unchanged", async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); mockPut.mockReturnValue(resolved({})); - const result = await service.updateRevenue('Test Revenue', mockRevenue); - expect(result).toEqual({ ...mockRevenue, name: 'Test Revenue' }); - expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ - TableName: 'test-revenue-table', - Item: expect.objectContaining({ name: 'Test Revenue' }), - })); + const result = await service.updateRevenue("Test Revenue", mockRevenue); + expect(result).toEqual({ ...mockRevenue, name: "Test Revenue" }); + expect(mockPut).toHaveBeenCalledWith( + expect.objectContaining({ + TableName: "test-revenue-table", + Item: expect.objectContaining({ name: "Test Revenue" }), + }), + ); expect(mockDelete).not.toHaveBeenCalled(); }); - it('should trim both the route param and body name before comparing', async () => { + it("should trim both the route param and body name before comparing", async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); mockPut.mockReturnValue(resolved({})); - const result = await service.updateRevenue(' Test Revenue ', { ...mockRevenue, name: ' Test Revenue ' }); - expect(result.name).toBe('Test Revenue'); + const result = await service.updateRevenue(" Test Revenue ", { + ...mockRevenue, + name: " Test Revenue ", + }); + expect(result.name).toBe("Test Revenue"); expect(mockDelete).not.toHaveBeenCalled(); }); - it('should put the new item and delete the old one when name changes', async () => { - mockGet.mockReturnValue(resolved({ Item: mockRevenue })); + it("should put the new item and delete the old one when name changes", async () => { + mockGet.mockImplementation((params) => { + if (params.Key.name === "Test Revenue") { + return resolved({ Item: mockRevenue }); + } + return resolved({}); + }); mockPut.mockReturnValue(resolved({})); mockDelete.mockReturnValue(resolved({})); - const renamed = { ...mockRevenue, name: 'New Name' }; - const result = await service.updateRevenue('Test Revenue', renamed); + const renamed = { ...mockRevenue, name: "New Name 15" }; + const result = await service.updateRevenue("Test Revenue", renamed); - expect(result.name).toBe('New Name'); - expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ - Item: expect.objectContaining({ name: 'New Name' }), - })); - expect(mockDelete).toHaveBeenCalledWith(expect.objectContaining({ - Key: { name: 'Test Revenue' }, - })); + expect(result.name).toBe("New Name 15"); + expect(mockPut).toHaveBeenCalledWith( + expect.objectContaining({ + Item: expect.objectContaining({ name: "New Name 15" }), + }), + ); + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { name: "Test Revenue" }, + }), + ); }); - it('should not call delete when trimmed names are equal despite different surrounding whitespace', async () => { + it("should not call delete when trimmed names are equal despite different surrounding whitespace", async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); mockPut.mockReturnValue(resolved({})); - await service.updateRevenue('Test Revenue', { ...mockRevenue, name: ' Test Revenue ' }); + await service.updateRevenue("Test Revenue", { + ...mockRevenue, + name: " Test Revenue ", + }); expect(mockDelete).not.toHaveBeenCalled(); }); - it('should use the route param name as the DynamoDB get key, not the body name', async () => { - mockGet.mockReturnValue(resolved({ Item: mockRevenue })); + it("should use the route param name as the DynamoDB get key, not the body name", async () => { + mockGet.mockImplementation((params) => { + if (params.Key.name === "Test Revenue 2") { + return resolved({ Item: mockRevenue }); + } + return resolved({}); + }); mockPut.mockReturnValue(resolved({})); - await service.updateRevenue('Test Revenue', { ...mockRevenue, name: 'Different Name' }); - expect(mockGet).toHaveBeenCalledWith(expect.objectContaining({ - Key: { name: 'Test Revenue' }, - })); - }); - - it('should throw InternalServerErrorException when delete fails after successful put during rename', async () => { - mockGet.mockReturnValue(resolved({ Item: mockRevenue })); + await service.updateRevenue("Test Revenue 2", { + ...mockRevenue, + name: "Different Name 52", + }); + expect(mockGet).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { name: "Test Revenue 2" }, + }), + ); + }); + + it("should throw InternalServerErrorException when delete fails after successful put during rename", async () => { + mockGet.mockImplementation((params) => { + if (params.Key.name === "Test Revenue") { + return resolved({ Item: mockRevenue }); + } + return resolved({}); + }); mockPut.mockReturnValue(resolved({})); - mockDelete.mockReturnValue(rejected(awsError('InternalServerError'))); + mockDelete.mockReturnValue(rejected(awsError("InternalServerError"))); await expect( - service.updateRevenue('Test Revenue', { ...mockRevenue, name: 'New Name' }) + service.updateRevenue("Test Revenue", { + ...mockRevenue, + name: "New Name 3", + }), ).rejects.toThrow(InternalServerErrorException); }); + it('should throw BadRequestException when renaming to a name that already exists', async () => { + mockGet.mockImplementation((params) => { + // Both the old name and the new name exist + if (params.Key.name === 'Test Revenue' || params.Key.name === 'Existing Revenue') { + return resolved({ Item: mockRevenue }); + } + return resolved({}); + }); + + await expect( + service.updateRevenue('Test Revenue', { ...mockRevenue, name: 'Existing Revenue' }) + ).rejects.toThrow(BadRequestException); + + expect(mockPut).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); +}); + // Existence check - it('should throw BadRequestException when item does not exist', async () => { + it("should throw BadRequestException when item does not exist", async () => { mockGet.mockReturnValue(resolved({ Item: undefined })); - await expect(service.updateRevenue('Nonexistent', mockRevenue)).rejects.toThrow(BadRequestException); + await expect( + service.updateRevenue("Nonexistent", mockRevenue), + ).rejects.toThrow(BadRequestException); expect(mockPut).not.toHaveBeenCalled(); }); - it('should throw InternalServerErrorException when get check throws an AWS error', async () => { - mockGet.mockReturnValue(rejected(awsError('ProvisionedThroughputExceededException'))); - await expect(service.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException when get check throws an AWS error", async () => { + mockGet.mockReturnValue( + rejected(awsError("ProvisionedThroughputExceededException")), + ); + await expect( + service.updateRevenue("Test Revenue", mockRevenue), + ).rejects.toThrow(InternalServerErrorException); }); - it('should throw InternalServerErrorException when get check throws an unexpected error', async () => { - mockGet.mockReturnValue(rejected(new Error('Network failure'))); - await expect(service.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException when get check throws an unexpected error", async () => { + mockGet.mockReturnValue(rejected(new Error("Network failure"))); + await expect( + service.updateRevenue("Test Revenue", mockRevenue), + ).rejects.toThrow(InternalServerErrorException); }); // Validation — body - it('should throw BadRequestException when revenue body is null', async () => { - await expect(service.updateRevenue('Test Revenue', null as any)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when revenue body is null", async () => { + await expect( + service.updateRevenue("Test Revenue", null as any), + ).rejects.toThrow(BadRequestException); }); // Validation — route name - it('should throw BadRequestException when route name is null', async () => { - await expect(service.updateRevenue(null as any, mockRevenue)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when route name is null", async () => { + await expect( + service.updateRevenue(null as any, mockRevenue), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when route name is empty', async () => { - await expect(service.updateRevenue('', mockRevenue)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when route name is empty", async () => { + await expect(service.updateRevenue("", mockRevenue)).rejects.toThrow( + BadRequestException, + ); }); - it('should throw BadRequestException when route name is only whitespace', async () => { - await expect(service.updateRevenue(' ', mockRevenue)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when route name is only whitespace", async () => { + await expect(service.updateRevenue(" ", mockRevenue)).rejects.toThrow( + BadRequestException, + ); }); // Validation — amount - it('should throw BadRequestException when amount is invalid', async () => { - await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, amount: -1 })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is invalid", async () => { + await expect( + service.updateRevenue("Test Revenue", { ...mockRevenue, amount: -1 }), + ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException when amount is NaN', async () => { - await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, amount: NaN })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when amount is NaN", async () => { + await expect( + service.updateRevenue("Test Revenue", { ...mockRevenue, amount: NaN }), + ).rejects.toThrow(BadRequestException); }); // Validation — type - it('should throw BadRequestException when type is invalid', async () => { - await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, type: 'bad' as RevenueType })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when type is invalid", async () => { + await expect( + service.updateRevenue("Test Revenue", { + ...mockRevenue, + type: "bad" as RevenueType, + }), + ).rejects.toThrow(BadRequestException); }); // Validation — body name - it('should throw BadRequestException when body name is empty', async () => { - await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, name: ' ' })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when body name is empty", async () => { + await expect( + service.updateRevenue("Test Revenue", { ...mockRevenue, name: " " }), + ).rejects.toThrow(BadRequestException); }); // Validation — installments - it('should throw BadRequestException when installments do not sum to amount', async () => { - await expect(service.updateRevenue('Test Revenue', { - ...mockRevenue, - amount: 1000, - installments: [{ amount: 300, date: '2024-01-01' as any }], - })).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when installments do not sum to amount", async () => { + await expect( + service.updateRevenue("Test Revenue", { + ...mockRevenue, + amount: 1000, + installments: [{ amount: 300, date: "2024-01-01" as any }], + }), + ).rejects.toThrow(BadRequestException); }); // AWS errors on put - it('should throw InternalServerErrorException on ThrottlingException during put', async () => { + it("should throw InternalServerErrorException on ThrottlingException during put", async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); - mockPut.mockReturnValue(rejected(awsError('ThrottlingException'))); - await expect(service.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + mockPut.mockReturnValue(rejected(awsError("ThrottlingException"))); + await expect( + service.updateRevenue("Test Revenue", mockRevenue), + ).rejects.toThrow(InternalServerErrorException); }); - it('should throw InternalServerErrorException on generic unexpected error during put', async () => { + it("should throw InternalServerErrorException on generic unexpected error during put", async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); - mockPut.mockReturnValue(rejected(new Error('Unexpected error'))); - await expect(service.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + mockPut.mockReturnValue(rejected(new Error("Unexpected error"))); + await expect( + service.updateRevenue("Test Revenue", mockRevenue), + ).rejects.toThrow(InternalServerErrorException); }); - it('should throw InternalServerErrorException when table name is not set', async () => { + it("should throw InternalServerErrorException when table name is not set", async () => { delete process.env.CASHFLOW_REVENUE_TABLE_NAME; - const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const module = await Test.createTestingModule({ + providers: [RevenueService], + }).compile(); const serviceNoTable = module.get(RevenueService); - await expect(serviceNoTable.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + await expect( + serviceNoTable.updateRevenue("Test Revenue", mockRevenue), + ).rejects.toThrow(InternalServerErrorException); }); }); // ─── deleteRevenue ────────────────────────────────────────────────────────── - describe('deleteRevenue', () => { - it('should delete a revenue item successfully', async () => { + describe("deleteRevenue", () => { + it("should delete a revenue item successfully", async () => { mockDelete.mockReturnValue(resolved({})); - await expect(service.deleteRevenue('Test Revenue')).resolves.toBeUndefined(); - expect(mockDelete).toHaveBeenCalledWith(expect.objectContaining({ - TableName: 'test-revenue-table', - Key: { name: 'Test Revenue' }, - ConditionExpression: 'attribute_exists(#name)', - })); - }); - - it('should trim the name before deleting', async () => { + await expect( + service.deleteRevenue("Test Revenue"), + ).resolves.toBeUndefined(); + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ + TableName: "test-revenue-table", + Key: { name: "Test Revenue" }, + ConditionExpression: "attribute_exists(#name)", + }), + ); + }); + + it("should trim the name before deleting", async () => { mockDelete.mockReturnValue(resolved({})); - await service.deleteRevenue(' Test Revenue '); - expect(mockDelete).toHaveBeenCalledWith(expect.objectContaining({ - Key: { name: 'Test Revenue' }, - })); + await service.deleteRevenue(" Test Revenue "); + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { name: "Test Revenue" }, + }), + ); }); - it('should throw BadRequestException when name is an empty string', async () => { - await expect(service.deleteRevenue('')).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is an empty string", async () => { + await expect(service.deleteRevenue("")).rejects.toThrow( + BadRequestException, + ); }); - it('should throw BadRequestException when name is only whitespace', async () => { - await expect(service.deleteRevenue(' ')).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is only whitespace", async () => { + await expect(service.deleteRevenue(" ")).rejects.toThrow( + BadRequestException, + ); }); - it('should throw BadRequestException when name is null', async () => { - await expect(service.deleteRevenue(null as any)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is null", async () => { + await expect(service.deleteRevenue(null as any)).rejects.toThrow( + BadRequestException, + ); }); - it('should throw BadRequestException when name is undefined', async () => { - await expect(service.deleteRevenue(undefined as any)).rejects.toThrow(BadRequestException); + it("should throw BadRequestException when name is undefined", async () => { + await expect(service.deleteRevenue(undefined as any)).rejects.toThrow( + BadRequestException, + ); }); - it('should throw InternalServerErrorException when item does not exist (ConditionalCheckFailedException)', async () => { - mockDelete.mockReturnValue(rejected(awsError('ConditionalCheckFailedException', 'Item does not exist'))); - await expect(service.deleteRevenue('Nonexistent')).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException when item does not exist (ConditionalCheckFailedException)", async () => { + mockDelete.mockReturnValue( + rejected( + awsError("ConditionalCheckFailedException", "Item does not exist"), + ), + ); + await expect(service.deleteRevenue("Nonexistent")).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on ResourceNotFoundException', async () => { - mockDelete.mockReturnValue(rejected(awsError('ResourceNotFoundException', 'Table not found'))); - await expect(service.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on ResourceNotFoundException", async () => { + mockDelete.mockReturnValue( + rejected(awsError("ResourceNotFoundException", "Table not found")), + ); + await expect(service.deleteRevenue("Test Revenue")).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on ThrottlingException', async () => { - mockDelete.mockReturnValue(rejected(awsError('ThrottlingException'))); - await expect(service.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on ThrottlingException", async () => { + mockDelete.mockReturnValue(rejected(awsError("ThrottlingException"))); + await expect(service.deleteRevenue("Test Revenue")).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException on generic unexpected error', async () => { - mockDelete.mockReturnValue(rejected(new Error('Unexpected error'))); - await expect(service.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + it("should throw InternalServerErrorException on generic unexpected error", async () => { + mockDelete.mockReturnValue(rejected(new Error("Unexpected error"))); + await expect(service.deleteRevenue("Test Revenue")).rejects.toThrow( + InternalServerErrorException, + ); }); - it('should throw InternalServerErrorException when table name is not set', async () => { + it("should throw InternalServerErrorException when table name is not set", async () => { delete process.env.CASHFLOW_REVENUE_TABLE_NAME; - const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const module = await Test.createTestingModule({ + providers: [RevenueService], + }).compile(); const serviceNoTable = module.get(RevenueService); - await expect(serviceNoTable.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + await expect( + serviceNoTable.deleteRevenue("Test Revenue"), + ).rejects.toThrow(InternalServerErrorException); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/revenue/cashflow-revenue.service.ts b/backend/src/revenue/cashflow-revenue.service.ts index f9b5f0bc..914b4b5e 100644 --- a/backend/src/revenue/cashflow-revenue.service.ts +++ b/backend/src/revenue/cashflow-revenue.service.ts @@ -14,7 +14,7 @@ import { Installment } from "../../../middle-layer/types/Installment"; export class RevenueService { private readonly logger = new Logger(RevenueService.name); private dynamoDb = new AWS.DynamoDB.DocumentClient(); - private revenueTableName : string = process.env.CASHFLOW_REVENUE_TABLE_NAME || "" + private revenueTableName : string = process.env.CASHFLOW_REVENUE_TABLE_NAME || ""; /** * Helper method to check if an error is an AWS error and extract relevant information */ @@ -206,6 +206,7 @@ private validateTableName(tableName : string){ } } + /** * Method to retrieve all of the revenue data * @returns All the revenue objects in the data base @@ -329,6 +330,15 @@ async updateRevenue(name: string, revenue: CashflowRevenue): Promise('authStore', { isAuthenticated: false, user: null, - accessToken: null, }) ``` @@ -53,10 +51,9 @@ import { action } from 'satcheljs'; // This will create and dispatch the action in one go. export let setAuthentication = action( 'SET_AUTHENTICATION', - (isAuthenticated: boolean, user: any, accessToken: string | null) => ({ + (isAuthenticated: boolean, user: any) => ({ isAuthenticated, user, - accessToken, }) ); ``` @@ -78,7 +75,6 @@ mutator(setAuthentication, actionMessage => { const store = getStore(); store.isAuthenticated = actionMessage.isAuthenticated; store.user = actionMessage.user; - store.accessToken = actionMessage.accessToken; }); ``` diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ba2596be..7eaf17e3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -45,22 +45,11 @@ export async function api( ): Promise { const cleanPath = path.startsWith('/') ? path : `/${path}`; const url = `${BASE}${cleanPath}`; - - const response = await fetch(url, { - credentials: 'include', - ...init, - }); - - if (response.status === 401) { - notifyCookieMissing(cleanPath); - } - - return response; const typedInit = init as ApiInit; const { __retry, ...fetchInit } = typedInit; const resp = await fetch(url, { - credentials: 'include', // send & receive the jwt cookie + credentials: 'include', ...fetchInit, }); @@ -75,5 +64,9 @@ export async function api( } } + if (resp.status === 401) { + notifyCookieMissing(cleanPath); + } + return resp; } diff --git a/frontend/src/components/ActionConfirmation.tsx b/frontend/src/components/ActionConfirmation.tsx index 04cc23e2..aef9e6b0 100644 --- a/frontend/src/components/ActionConfirmation.tsx +++ b/frontend/src/components/ActionConfirmation.tsx @@ -37,9 +37,9 @@ const ActionConfirmation = ({ labelClass: "text-green", textClass: "text-green-dark", cancelClass: - "!border-2 active:!border-grey-500 border-grey-500 bg-white text-black active:!text-grey-600 hover:!border-grey-600 hover:bg-grey-150 active:bg-grey-200", + "!border-2 active:!border-grey-500 border-grey-500 active:!bg-white text-black active:!text-grey-600 hover:!border-grey-600 hover:bg-grey-150 active:bg-grey-200", confirmClass: - "!border-0 bg-green text-white hover:!border-green hover:bg-green-dark active:bg-green-dark active:!bg-opacity-75", + "!border-2 bg-green text-white hover:!border-green hover:bg-green-dark active:!bg-green-dark active:!bg-opacity-75 active:!border-green", } : variant === "update" ? { @@ -52,9 +52,9 @@ const ActionConfirmation = ({ labelClass: "text-yellow-dark", textClass: "text-yellow-dark", cancelClass: - "!border-2 active:!border-grey-500 border-grey-500 bg-white text-black active:!text-grey-600 hover:!border-grey-600 hover:bg-grey-150 active:bg-grey-200", + "!border-2 active:!border-grey-500 border-grey-500 active:!bg-white text-black active:!text-grey-600 hover:!border-grey-600 hover:bg-grey-150 active:bg-grey-200", confirmClass: - "!border-0 bg-yellow text-white hover:!border-yellow hover:bg-opacity-75 active:bg-yellow", + "!border-2 bg-yellow text-white hover:!border-yellow hover:bg-opacity-75 active:bg-yellow active:!border-yellow active:!bg-yellow-dark", } : { panel: "border-t-4 border-red", @@ -66,9 +66,9 @@ const ActionConfirmation = ({ labelClass: "text-red", textClass: "text-red", cancelClass: - "!border-0 bg-red text-white hover:!border-red hover:bg-opacity-75 active:bg-red", + "!border-2 bg-red text-white hover:!border-red hover:bg-opacity-75 active:bg-red active:!border-red active:!bg-red-dark", confirmClass: - "!border-2 active:!border-grey-500 border-grey-500 bg-white text-black active:!text-grey-600 hover:!border-grey-600 hover:bg-grey-150 active:bg-grey-200", + "!border-2 active:!border-grey-500 border-grey-500 active:!bg-white text-black active:!text-grey-600 hover:!border-grey-600 hover:bg-grey-150 active:bg-grey-200", }; const { Icon } = styles; diff --git a/frontend/src/components/CheckboxField.tsx b/frontend/src/components/CheckboxField.tsx new file mode 100644 index 00000000..adfffce9 --- /dev/null +++ b/frontend/src/components/CheckboxField.tsx @@ -0,0 +1,59 @@ +import { faCheck } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type CheckboxFieldProps = { + id: string; + checked: boolean; + onChange: () => void; + label?: React.ReactNode; +}; + +/** + * Reusable styled checkbox rendered as an ARIA checkbox. + */ +export default function CheckboxField({ + id, + checked, + onChange, + label, +}: CheckboxFieldProps) { + const labelId = `${id}-label`; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onChange(); + } + }; + + return ( + + ); +} diff --git a/frontend/src/components/InputField.tsx b/frontend/src/components/InputField.tsx index 71b5e287..3c168fc0 100644 --- a/frontend/src/components/InputField.tsx +++ b/frontend/src/components/InputField.tsx @@ -25,12 +25,12 @@ export default function InputField({ {label} {required && *} -
+
diff --git a/frontend/src/context/auth/authContext.tsx b/frontend/src/context/auth/authContext.tsx index 187badfa..2e0a312a 100644 --- a/frontend/src/context/auth/authContext.tsx +++ b/frontend/src/context/auth/authContext.tsx @@ -81,7 +81,7 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => const data = await response.json(); if (response.ok && data.user) { console.log("Login successful:", data.user); - setAuthState(true, data.user, null); + setAuthState(true, data.user); await fetchAllData(); return true; } else { diff --git a/frontend/src/external/bcanSatchel/actions.ts b/frontend/src/external/bcanSatchel/actions.ts index 424d2225..705ae858 100644 --- a/frontend/src/external/bcanSatchel/actions.ts +++ b/frontend/src/external/bcanSatchel/actions.ts @@ -6,6 +6,8 @@ import { Notification } from '../../../../middle-layer/types/Notification'; import { CashflowCost } from '../../../../middle-layer/types/CashflowCost'; import { CashflowRevenue } from '../../../../middle-layer/types/CashflowRevenue'; import { CashflowSettings } from '../../../../middle-layer/types/CashflowSettings'; +import { RevenueType } from '../../../../middle-layer/types/RevenueType'; +import { CostType } from '../../../../middle-layer/types/CostType'; /** * Set whether the user is authenticated, update the user object, @@ -13,10 +15,9 @@ import { CashflowSettings } from '../../../../middle-layer/types/CashflowSetting */ export const setAuthState = action( "setAuthState", - (isAuthenticated: boolean, user: User, accessToken: string | null) => ({ + (isAuthenticated: boolean, user: User) => ({ isAuthenticated, user, - accessToken, }) ); @@ -64,7 +65,7 @@ export const setCashflowSettings = action("setCashflowSettings", (cashflowSettings: CashflowSettings) => ({ cashflowSettings }) ); -export const updateFilter = action("updateFilter", (status: Status | null) => ({ +export const updateFilter = action("updateFilter", (status: Status[]) => ({ status, })); @@ -130,3 +131,13 @@ export const setNotifications = action( 'setNotifications', (notifications: Notification[]) => ({notifications}) ) + +export const updateRevenueCategoryFilter = action( + 'updateRevenueCategoryFilter', + (category: RevenueType[]) => ({ category }) +); + +export const updateCostCategoryFilter = action( + 'updateCostCategoryFilter', + (category: CostType[]) => ({ category }) +); diff --git a/frontend/src/external/bcanSatchel/mutators.ts b/frontend/src/external/bcanSatchel/mutators.ts index 7ab5dcc2..24fd39ee 100644 --- a/frontend/src/external/bcanSatchel/mutators.ts +++ b/frontend/src/external/bcanSatchel/mutators.ts @@ -22,7 +22,9 @@ import { removeProfilePic, fetchCashflowRevenues, fetchCashflowCosts, - setCashflowSettings + setCashflowSettings, + updateRevenueCategoryFilter, + updateCostCategoryFilter } from "./actions"; import { getAppStore, persistToSessionStorage } from "./store"; @@ -53,7 +55,6 @@ mutator(setAuthState, (actionMessage) => { console.log("Setting user:", actionMessage.user); store.isAuthenticated = actionMessage.isAuthenticated; store.user = actionMessage.user; - store.accessToken = actionMessage.accessToken; console.log("Calling persistToSessionStorage..."); persistToSessionStorage(); }); @@ -104,14 +105,13 @@ mutator(logoutUser, () => { const store = getAppStore(); store.isAuthenticated = false; store.user = null; - store.accessToken = null; sessionStorage.removeItem("bcanAppStore"); }); // Clears all store filters mutator(clearAllFilters, () => { const store = getAppStore(); - store.filterStatus = null; + store.filterStatus = []; store.startDateFilter = null; store.endDateFilter = null; store.searchQuery = ""; @@ -121,6 +121,8 @@ mutator(clearAllFilters, () => { store.eligibleOnly = false; store.amountMinFilter = null; store.amountMaxFilter = null; + store.filterRevenueCategory = []; + store.filterCostCategory = []; }); /** @@ -149,6 +151,7 @@ mutator(updateFilter, (actionMessage) => { store.filterStatus = actionMessage.status; }); + mutator(updateStartDateFilter, (actionMessage) => { const store = getAppStore(); store.startDateFilter = actionMessage.startDateFilter; @@ -247,3 +250,13 @@ mutator(setCashflowSettings, (actionMessage) => { const store = getAppStore(); store.cashflowSettings = actionMessage.cashflowSettings; }); + +mutator(updateRevenueCategoryFilter, (actionMessage) => { + const store = getAppStore(); + store.filterRevenueCategory = actionMessage.category; +}); + +mutator(updateCostCategoryFilter, (actionMessage) => { + const store = getAppStore(); + store.filterCostCategory = actionMessage.category; +}); diff --git a/frontend/src/external/bcanSatchel/store.ts b/frontend/src/external/bcanSatchel/store.ts index cd64a805..2f1b3573 100644 --- a/frontend/src/external/bcanSatchel/store.ts +++ b/frontend/src/external/bcanSatchel/store.ts @@ -6,13 +6,14 @@ import { Notification } from '../../../../middle-layer/types/Notification' import { CashflowRevenue } from '../../../../middle-layer/types/CashflowRevenue' import { CashflowCost } from '../../../../middle-layer/types/CashflowCost' import { CashflowSettings } from '../../../../middle-layer/types/CashflowSettings' +import { RevenueType } from '../../../../middle-layer/types/RevenueType' +import { CostType } from '../../../../middle-layer/types/CostType' export interface AppState { isAuthenticated: boolean; user: User | null; - accessToken: string | null; allGrants: Grant[] | [] - filterStatus: Status | null; + filterStatus: Status[]; // TODO: should this be the ISODate type? startDateFilter: Date | null; endDateFilter: Date | null; @@ -31,15 +32,16 @@ export interface AppState { revenueSources: CashflowRevenue[]; costSources: CashflowCost[]; cashflowSettings: CashflowSettings | null; + filterRevenueCategory: RevenueType[]; + filterCostCategory: CostType[]; } // Define initial state const initialState: AppState = { isAuthenticated: false, user: null, - accessToken: null, allGrants: [], - filterStatus: null, + filterStatus: [], startDateFilter: null, endDateFilter: null, searchQuery: '', @@ -57,6 +59,8 @@ const initialState: AppState = { revenueSources: [], costSources: [], cashflowSettings: null, + filterRevenueCategory: [], + filterCostCategory: [], }; /** @@ -72,7 +76,6 @@ function hydrateFromSessionStorage(): AppState { ...initialState, isAuthenticated: data.isAuthenticated ?? false, user: data.user ?? null, - accessToken: data.accessToken ?? null, activeUsers: data.activeUsers ?? [], inactiveUsers: data.inactiveUsers ?? [], }; @@ -96,7 +99,6 @@ export function persistToSessionStorage() { const dataToSave = { isAuthenticated: state.isAuthenticated, user: state.user ? JSON.parse(JSON.stringify(state.user)) : null, - accessToken: state.accessToken, activeUsers: state.activeUsers ? state.activeUsers.map(u => JSON.parse(JSON.stringify(u))) : [], inactiveUsers: state.inactiveUsers ? state.inactiveUsers.map(u => JSON.parse(JSON.stringify(u))) : [], }; diff --git a/frontend/src/login/Login.tsx b/frontend/src/login/Login.tsx index 74065cca..a343000b 100644 --- a/frontend/src/login/Login.tsx +++ b/frontend/src/login/Login.tsx @@ -13,11 +13,13 @@ const Login = observer(() => { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [failure, setFailure] = useState(false); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); const { login } = useAuthContext(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setLoading(true); const success = await login(email, password); @@ -26,6 +28,8 @@ const Login = observer(() => { } else { setFailure(true); } + + setLoading(false); }; return ( @@ -65,8 +69,8 @@ const Login = observer(() => { )}
+ + )} ); } diff --git a/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx b/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx index 63efe2b0..280ba40d 100644 --- a/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx +++ b/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx @@ -1,11 +1,13 @@ -import { useId } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { CostType } from "../../../../../middle-layer/types/CostType"; import { RevenueType } from "../../../../../middle-layer/types/RevenueType"; import { Frequency } from "../../../../../middle-layer/types/Frequency"; type CashCategoryDropdown = { type: typeof RevenueType | typeof CostType | typeof Frequency; - onChange: (e: React.ChangeEvent) => void; + onValueChange: (value: RevenueType | CostType | Frequency) => void; value: RevenueType | CostType | Frequency | ""; name?: string; error?: boolean; @@ -13,56 +15,101 @@ type CashCategoryDropdown = { export default function CashCategoryDropdown({ type, - onChange, + onValueChange, value, name = "Category", error, }: CashCategoryDropdown) { const generatedId = useId(); + const [isOpen, setIsOpen] = useState(false); + const wrapperRef = useRef(null); + const options = useMemo( + () => Object.values(type) as Array, + [type], + ); + const selectedLabel = value || `Select a ${name}`; + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if (!wrapperRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("keydown", handleEscape); + }; + }, []); + + const handleSelect = (selectedValue: RevenueType | CostType | Frequency) => { + onValueChange(selectedValue); + setIsOpen(false); + }; return ( -
+
-
- - - {/* Custom dropdown arrow */} -
- - - -
-
+ + ) : null}
+
); } diff --git a/frontend/src/main-page/cash-flow/components/CashCreateLineItem.tsx b/frontend/src/main-page/cash-flow/components/CashCreateLineItem.tsx index 69694d39..f5799ea7 100644 --- a/frontend/src/main-page/cash-flow/components/CashCreateLineItem.tsx +++ b/frontend/src/main-page/cash-flow/components/CashCreateLineItem.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import Button from "../../../components/Button"; -import CashAddRevenue from "./CashAddRevenue"; +import CashAddEditRevenue from "./CashAddEditRevenue"; import CashAddEditCost from "./CashAddEditCost"; export default function CashCreateLineItem() { @@ -42,7 +42,7 @@ export default function CashCreateLineItem() {
- {!showCosts && } + {!showCosts && } {showCosts && }
diff --git a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx index 1e879d1b..36118b77 100644 --- a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx +++ b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import Button from "../../../components/Button"; -import { faTrash, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; +import { faTrash, faPenToSquare, faArrowRight } from "@fortawesome/free-solid-svg-icons"; import ActionConfirmation from "../../../components/ActionConfirmation"; type CashEditLineItemProps = { @@ -8,6 +8,9 @@ type CashEditLineItemProps = { children: (onClose: () => void) => React.ReactNode; sourceName: string; onRemove: () => Promise; + isReadOnly: boolean; + onReadOnlyAction?: () => void; + inactive?: boolean; }; export default function CashEditLineItem({ @@ -15,6 +18,9 @@ export default function CashEditLineItem({ children, sourceName, onRemove, + isReadOnly, + onReadOnlyAction, + inactive = false, }: CashEditLineItemProps) { const [editing, setEditing] = useState(false); @@ -30,17 +36,20 @@ export default function CashEditLineItem({ }; return ( -
+
+
{!editing && ( -
+
-
+
{sourceName}
{cardText}
+ {!isReadOnly && ( + <>
)} {editing &&
{children(() => setEditing(false))}
} - + setShowDeleteModal(false)} onConfirmDelete={() => { diff --git a/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx deleted file mode 100644 index bc59df2f..00000000 --- a/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { useState } from "react"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; -import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; -import { Installment } from "../../../../../middle-layer/types/Installment"; -import { RevenueType } from "../../../../../middle-layer/types/RevenueType"; -import Button from "../../../components/Button"; -import InputField from "../../../components/InputField"; -import { saveRevenueEdits } from "../processCashflowDataEditSave"; -import ActionConfirmation from "../../../components/ActionConfirmation"; -import CashCategoryDropdown from "./CashCategoryDropdown"; -import CashRevenueInstallment, { - EditableInstallment, -} from "./CashRevenueInstallment"; -import { formatMoney } from "../CashFlowPage"; - -type CashEditRevenueProps = { - revenueItem: CashflowRevenue; - onClose: () => void; -}; - -type FieldErrors = { - type?: string; - name?: string; - singleAmount?: string; - singleDate?: string; - installments?: string; - submit?: string; -}; - -const EMPTY_INSTALLMENT: EditableInstallment = { - amount: null, - date: null, -}; - -const toDateValue = (dateValue: Date | string | null | undefined) => { - if (!dateValue) { - return null; - } - - const parsedDate = new Date(dateValue); - if (Number.isNaN(parsedDate.getTime())) { - return null; - } - - return parsedDate; -}; - -const toEditableInstallment = ( - installment: Installment, -): EditableInstallment => ({ - amount: Number.isFinite(installment.amount) ? installment.amount : null, - date: toDateValue(installment.date), -}); - -export default function CashEditRevenue({ - revenueItem, - onClose, -}: CashEditRevenueProps) { - const initialInstallments = revenueItem.installments.map(toEditableInstallment); - const [name, setName] = useState(revenueItem.name); - const [type, setType] = useState(revenueItem.type); - const [isMultipleInstallments, setIsMultipleInstallments] = useState( - initialInstallments.length > 1, - ); - const [singleInstallment, setSingleInstallment] = useState( - initialInstallments[0] ?? EMPTY_INSTALLMENT, - ); - const [installments, setInstallments] = useState( - initialInstallments.length > 1 ? initialInstallments : [], - ); - const [errors, setErrors] = useState({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showConfirmModal, setShowConfirmModal] = useState(false); - const [pendingRevenue, setPendingRevenue] = useState( - null, - ); - - const isValidInstallment = (installment: EditableInstallment) => { - if (installment.amount === null || installment.date === null) { - return false; - } - - return ( - Number.isFinite(installment.amount) && - installment.amount > 0 && - !Number.isNaN(installment.date.getTime()) - ); - }; - - const toInstallment = (installment: EditableInstallment): Installment => ({ - amount: installment.amount as number, - date: installment.date as Date, - }); - - const buildPayload = (): CashflowRevenue | null => { - const nextErrors: FieldErrors = {}; - - if (!type) { - nextErrors.type = "Please select a category."; - } - - if (!name.trim()) { - nextErrors.name = "Please enter a name."; - } - - let cleanedInstallments: Installment[] = []; - if (isMultipleInstallments) { - const allValid = - installments.length > 0 && installments.every(isValidInstallment); - - if (!allValid) { - nextErrors.installments = - "Please fill all installment amounts and dates with valid values."; - } else { - cleanedInstallments = installments.map(toInstallment); - } - } else { - if (singleInstallment.amount === null || singleInstallment.amount <= 0) { - nextErrors.singleAmount = "Amount must be greater than 0."; - } - - if ( - singleInstallment.date === null || - Number.isNaN(singleInstallment.date.getTime()) - ) { - nextErrors.singleDate = "Please enter a valid date."; - } - - if (!nextErrors.singleAmount && !nextErrors.singleDate) { - cleanedInstallments = [toInstallment(singleInstallment)]; - } - } - - setErrors(nextErrors); - if (Object.keys(nextErrors).length > 0 || !type) { - return null; - } - - const totalAmount = cleanedInstallments.reduce( - (sum, installment) => sum + installment.amount, - 0, - ); - - return { - amount: totalAmount, - type, - name: name.trim(), - installments: cleanedInstallments, - }; - }; - - const addInstallment = () => { - if (!isMultipleInstallments) { - setInstallments([singleInstallment, EMPTY_INSTALLMENT]); - setIsMultipleInstallments(true); - return; - } - - setInstallments((previousInstallments) => [ - ...previousInstallments, - EMPTY_INSTALLMENT, - ]); - }; - - const updateInstallment = ( - installmentIndex: number, - key: "amount" | "date", - value: number | Date | null, - ) => { - setInstallments((previousInstallments) => - previousInstallments.map((installment, index) => - index === installmentIndex - ? { ...installment, [key]: value } - : installment, - ), - ); - }; - - const removeInstallment = (installmentIndex: number) => { - setInstallments((previousInstallments) => { - const updatedInstallments = previousInstallments.filter( - (_, index) => index !== installmentIndex, - ); - - if (updatedInstallments.length <= 1) { - setIsMultipleInstallments(false); - setSingleInstallment(updatedInstallments[0] ?? EMPTY_INSTALLMENT); - return []; - } - - return updatedInstallments; - }); - }; - - const totalAmount = isMultipleInstallments - ? installments.reduce( - (sum, installment) => sum + (installment.amount ?? 0), - 0, - ) - : (singleInstallment.amount ?? 0); - - const requestSave = () => { - const payload = buildPayload(); - if (!payload) { - return; - } - setPendingRevenue(payload); - setShowConfirmModal(true); - }; - - const handleConfirmedSave = async () => { - if (!pendingRevenue) return; - const payload = pendingRevenue; - - setIsSubmitting(true); - setErrors((previous) => ({ ...previous, submit: undefined })); - - const result = await saveRevenueEdits(revenueItem.name, payload); - if (!result.success) { - setErrors((previous) => ({ - ...previous, - submit: result.error || "Unable to update revenue source.", - })); - setIsSubmitting(false); - return; - } - - setIsSubmitting(false); - onClose(); - }; - - return ( -
- { - setShowConfirmModal(false); - setPendingRevenue(null); - }} - onConfirmDelete={() => { - void handleConfirmedSave(); - setPendingRevenue(null); - }} - title="Update Revenue Source" - subtitle="Are you sure you want to save changes to" - boldSubtitle={pendingRevenue?.name ?? revenueItem.name} - warningMessage="This will update this revenue line in your cash flow." - variant="update" - /> -
-
- setName(event.target.value)} - /> - {errors.name ?

{errors.name}

: null} -
-
- { - const nextType = event.target.value; - setType(nextType ? (nextType as RevenueType) : null); - }} - value={type ?? ""} - error={Boolean(errors.type)} - /> - {errors.type ?

{errors.type}

: null} -
-
- - {isMultipleInstallments ? ( - <> - {installments.map((installment, index) => ( - updateInstallment(index, "amount", value)} - onDateChange={(value) => updateInstallment(index, "date", value)} - onDelete={() => removeInstallment(index)} - /> - ))} - {errors.installments ? ( -

{errors.installments}

- ) : null} - - ) : ( -
-
- - setSingleInstallment((previous) => ({ - ...previous, - amount: event.target.value === "" ? null : Number(event.target.value), - })) - } - /> - {errors.singleAmount ? ( -

{errors.singleAmount}

- ) : null} -
-
- - setSingleInstallment((previous) => ({ - ...previous, - date: event.target.value - ? new Date(`${event.target.value}T00:00:00`) - : null, - })) - } - /> - {errors.singleDate ? ( -

{errors.singleDate}

- ) : null} -
-
- )} - - {errors.submit ?

{errors.submit}

: null} - -
-
-
- ); -} diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index 0efe352b..672610a4 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -1,20 +1,26 @@ -import { CashflowCost } from "../../../../../middle-layer/types/CashflowCost"; -import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; +import { CashflowKPIs, ChartDataPoint } from "../projection"; import CashProjectionChart from "./CashProjectionChart"; type ProjectionProps = { - costs: CashflowCost[]; - revenues: CashflowRevenue[]; + data: ChartDataPoint[]; + kpis: CashflowKPIs; }; -export default function CashProjection({costs, revenues}:ProjectionProps) { - +export default function CashProjection({ data, kpis }: ProjectionProps) { // replace with actual data const cards = [ - { field: "Final Balance", value: 38784, color: "text-blue" }, - { field: "Lowest Point", value: 38784, color: "text-grey" }, - { field: "Total Revenue", value: 20000, color: "text-green" }, - { field: "36-Mo Costs", value: 31212, color: "text-primary" }, + { + field: "Final Projected Balance", + value: kpis.finalBalance, + color: "text-blue", + }, + { + field: "Lowest Point", + value: kpis.lowestBalancePoint, + color: "text-grey", + }, + { field: "Total Revenue", value: kpis.totalRevenue, color: "text-green" }, + { field: "Total Costs", value: kpis.totalCosts, color: "text-primary" }, ]; return ( @@ -22,12 +28,12 @@ export default function CashProjection({costs, revenues}:ProjectionProps) {
{"36-Month Cash Flow Projection"}
- +
{cards.map((c) => (
{c.field}
diff --git a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx index de58a8da..9e34f16b 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx @@ -9,42 +9,60 @@ import { } from "recharts"; import { observer } from "mobx-react-lite"; import "../../dashboard/styles/Dashboard.css"; -import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; -import { CashflowCost } from "../../../../../middle-layer/types/CashflowCost"; +import { ChartDataPoint } from "../projection"; -type ChartProps = { - costs: CashflowCost[]; - revenues: CashflowRevenue[]; +type ProjectionProps = { + data: ChartDataPoint[]; }; -const CashProjectionChart = observer(({}: ChartProps) => { - - // replace with actual data, filter for 36 months - const data = [ - { date: new Date(), cash_balance: 68333, revenue: 10000, costs: 833 }, - { - date: new Date("2026-04-20"), - cash_balance: 7856, - revenue: 19000, - costs: 793, - }, - { - date: new Date("2026-05-19"), - cash_balance: 98000, - revenue: 16789, - costs: 1000, - }, - ]; - - // Sort by date to ensure correct line order - data.sort((a, b) => a.date.getTime() - b.date.getTime()); +const formatMonthYear = (ts: number) => + new Date(ts).toLocaleDateString("en-US", { + month: "short", + year: "2-digit", + }); + +const generateMonthlyTicks = (data: ChartDataPoint[]) => { + if (!data.length) return []; + + const sorted = [...data].sort((a, b) => a.month - b.month); + + const start = new Date(sorted[0].month); + const end = new Date(sorted[sorted.length - 1].month); + + // normalize to first of month + const current = new Date(start.getFullYear(), start.getMonth(), 1); + + const ticks: number[] = []; + + while (current <= end) { + ticks.push(current.getTime()); + current.setMonth(current.getMonth() + 1); + } + + const filteredTicks = ticks.filter((_, i) => i % 6 === 0) + + return filteredTicks; +}; + +const CashProjectionChart = observer(({ data }: ProjectionProps) => { + + const normalizeToMonthStart = (ts: number) => { + const d = new Date(ts); + return new Date(d.getFullYear(), d.getMonth(), 1).getTime(); +}; + +const normalizedData = data.map(d => ({ + ...d, + month: normalizeToMonthStart(d.month), +})); + return (
{ /> - date.getMonth().toLocaleString() + "/" + date.getFullYear()} - axisLine={false} - tickLine={false} + domain={["dataMin", "dataMax"]} + ticks={generateMonthlyTicks(data)} + axisLine={true} + tickLine={true} + tickFormatter={formatMonthYear} + interval="preserveStart" + tick={{ fontSize: 12, dy: 10, textAnchor: "middle" }} + className="axis" /> - `$${(v / 1000).toFixed(0)}k`} @@ -103,11 +120,12 @@ const CashProjectionChart = observer(({}: ChartProps) => { backgroundColor: "white", border: "1px solid lightgray", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", + textAlign: "left", }} - labelFormatter={(date: Date) => - date.getMonth().toLocaleString() + "/" + date.getFullYear() + labelFormatter={formatMonthYear} + formatter={(value: number) => + `$${value.toLocaleString("en-US", { minimumFractionDigits: 2 })}` } - formatter={(value: number) => `$${value.toLocaleString()}`} /> diff --git a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx index 440bb0de..361b36a5 100644 --- a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx +++ b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx @@ -1,6 +1,5 @@ import InputField from "../../../components/InputField"; -import { faTrash } from "@fortawesome/free-solid-svg-icons"; -import Button from "../../../components/Button"; +import { FaXmark } from "react-icons/fa6"; export type EditableInstallment = { amount: number | null; @@ -72,13 +71,12 @@ export default function CashRevenueInstallment({ />
{showDelete ? ( -
+ ); +}); + +export default CategoryFilter; diff --git a/frontend/src/main-page/cash-flow/processCashflowData.ts b/frontend/src/main-page/cash-flow/processCashflowData.ts index 35a6d566..1c969d2d 100644 --- a/frontend/src/main-page/cash-flow/processCashflowData.ts +++ b/frontend/src/main-page/cash-flow/processCashflowData.ts @@ -1,10 +1,15 @@ import { useEffect } from "react"; import { getAppStore } from "../../external/bcanSatchel/store.ts"; import { fetchCashflowCosts, fetchCashflowRevenues, setCashflowSettings } from "../../external/bcanSatchel/actions.ts"; -import {CashflowRevenue} from "../../../../middle-layer/types/CashflowRevenue.ts"; +import {CashflowRevenue, GrantPageGrant} from "../../../../middle-layer/types/CashflowRevenue.ts"; import {CashflowCost} from "../../../../middle-layer/types/CashflowCost.ts"; import {CashflowSettings} from "../../../../middle-layer/types/CashflowSettings.ts"; +import { Grant } from "../../../../middle-layer/types/Grant.ts"; +import { RevenueType } from "../../../../middle-layer/types/RevenueType.ts"; +import { Status } from "../../../../middle-layer/types/Status.ts"; import { api } from "../../api.ts"; +import { Frequency } from "../../../../middle-layer/types/Frequency.ts"; +import { TDateISO } from "../../../../backend/src/utils/date.ts"; // This has not been tested yet but the basic structure when implemented should be the same // Mirrored format for processGrantData.ts @@ -26,12 +31,39 @@ export const fetchCosts = async () => { export const fetchRevenues = async () => { try { - const response = await api("/cashflow-revenue"); - if (!response.ok) { - throw new Error(`HTTP Error, Status: ${response.status}`); + const [revenueResponse, grantResponse] = await Promise.all([ + api("/cashflow-revenue"), + api("/grant"), + ]); + + if (!revenueResponse.ok) { + throw new Error(`HTTP Error, Status: ${revenueResponse.status}`); } - const updatedRevenues: CashflowRevenue[] = await response.json(); - fetchCashflowRevenues(updatedRevenues); + + if (!grantResponse.ok) { + throw new Error(`HTTP Error, Status: ${grantResponse.status}`); + } + + const updatedRevenues: CashflowRevenue[] = await revenueResponse.json(); + const grants: Grant[] = await grantResponse.json(); + + const mappedActiveGrantRevenues: GrantPageGrant[] = grants + .filter((grant) => grant.status === Status.Active) + .map((grant) => ({ + amount: grant.amount, + type: RevenueType.Grants, + name: grant.organization.trim(), + installments: [ + { + amount: grant.amount, + date: new Date(grant.grant_start_date), + }, + ], + isGrantBased: true, + grantId: grant.grantId, + })); + + fetchCashflowRevenues([...updatedRevenues, ...mappedActiveGrantRevenues]); } catch (error) { console.error("Error fetching revenues:", error); } @@ -50,6 +82,29 @@ export const fetchCashflowSettings = async () => { } }; +export const isInactive = (item: CashflowRevenue | CashflowCost) => { + const { cashflowSettings } = getAppStore(); + const refDate = cashflowSettings?.startDate + ? new Date(cashflowSettings.startDate) + : new Date(); + refDate.toISOString().split("T")[0] as TDateISO; + + if ('frequency' in item && 'date' in item && item.frequency === Frequency.OneTime) { + const itemDate = new Date(item.date); + itemDate.toISOString().split("T")[0] as TDateISO; + return itemDate < refDate; + } + if ('installments' in item) { + const futureInstallments = item.installments.filter(installment => { + const instDate = new Date(installment.date); + instDate.toISOString().split("T")[0] as TDateISO; + return instDate > refDate; + }); + return futureInstallments.length === 0; + } + return false; +}; + // could contain callbacks for sorting and filtering line items // stores state for list of costs/revenues @@ -75,8 +130,12 @@ export const ProcessCashflowData = () => { if (!cashflowSettings) fetchCashflowSettings(); }, [cashflowSettings]); - const sortedCosts = costSources.slice().sort((a, b) => a.name.localeCompare(b.name)); - const sortedRevenues = revenueSources.slice().sort((a, b) => a.name.localeCompare(b.name)); + const sortFn = (a: CashflowCost | CashflowRevenue, b: CashflowCost | CashflowRevenue) => + (isInactive(a) === isInactive(b) ? 0 : isInactive(a) ? 1 : -1) || + a.name.localeCompare(b.name); + + const sortedCosts = costSources.slice().sort(sortFn); + const sortedRevenues = revenueSources.slice().sort(sortFn); return { costs: sortedCosts, revenues: sortedRevenues, cashflowSettings }; }; diff --git a/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts b/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts index d34dd69b..854c3e3f 100644 --- a/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts +++ b/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts @@ -220,6 +220,9 @@ export const saveCashflowSettings = async (settings: CashflowSettings) => { ]; for (const update of updates) { + if (update.value === undefined || update.value === null || (Number.isNaN(update.value) && typeof update.value === "number")) { + continue; // Skip undefined or null values + } const response = await api("/default-values", { method: "PUT", headers: { "Content-Type": "application/json" }, diff --git a/frontend/src/main-page/cash-flow/projection.ts b/frontend/src/main-page/cash-flow/projection.ts new file mode 100644 index 00000000..23c48996 --- /dev/null +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -0,0 +1,212 @@ +import { TDateISO } from "../../../../backend/src/utils/date"; +import { CashflowCost } from "../../../../middle-layer/types/CashflowCost"; +import { CashflowRevenue } from "../../../../middle-layer/types/CashflowRevenue"; +import { CashflowSettings } from "../../../../middle-layer/types/CashflowSettings"; +import { CostType } from "../../../../middle-layer/types/CostType"; +import { + Frequency, + frequencyIntervalsInMonths, +} from "../../../../middle-layer/types/Frequency"; + +/** One data point per month for the Recharts line chart */ +export interface ChartDataPoint { + /** End-of-month timestamp (ms since epoch) for the time-scale x-axis */ + month: number; + /** Cumulative cash balance at end of month */ + cashBalance: number; + /** Total revenue received this month */ + revenue: number; + /** Total costs incurred this month */ + costs: number; +} + +export interface CashflowKPIs { + finalBalance: number; + lowestBalancePoint: number; + totalRevenue: number; + totalCosts: number; +} + +export interface CashflowProjectionResult { + chartData: ChartDataPoint[]; + kpis: CashflowKPIs; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +const PROJECTION_MONTHS = 36; + +/** Returns a month key like "2025-01" from a Date or date string. + * For strings, parses directly to avoid UTC→local timezone shift. */ +function toMonthKey(input: Date | string): string { + if (typeof input === "string") { + // Parse "YYYY-MM-DD" or "YYYY-MM-..." directly — no timezone ambiguity + const [y, m] = input.split("-"); + return `${y}-${m.padStart(2, "0")}`; + } + const y = input.getFullYear(); + const m = String(input.getMonth() + 1).padStart(2, "0"); + return `${y}-${m}`; +} + +/** Returns the end-of-month as a ms timestamp */ +function toEndOfMonth(key: string): number { + const [y, m] = key.split("-").map(Number); + // Day 0 of the next month = last day of current month + return new Date(y, m, 0).getTime(); +} + +/** Generate an ordered list of 36 month keys starting from startDate */ +function generateMonthKeys(startDate: TDateISO): string[] { + const [y, m] = startDate.split("-").map(Number); + const keys: string[] = []; + for (let i = 0; i < PROJECTION_MONTHS; i++) { + const date = new Date(y, m - 1 + i, 1); // local date, no UTC shift + keys.push(toMonthKey(date)); + } + return keys; +} + +/** How many full years have elapsed between two month keys (for annual increases) */ +function yearsElapsed(startKey: string, currentKey: string): number { + const [sy, sm] = startKey.split("-").map(Number); + const [cy, cm] = currentKey.split("-").map(Number); + const totalMonths = (cy - sy) * 12 + (cm - sm); + return Math.floor(totalMonths / 12); +} + +/** + * Returns the effective interval in months for a cost's frequency. + * For Custom frequency the user-supplied interval is used. + */ +function getIntervalMonths(cost: CashflowCost): number { + if (cost.frequency === Frequency.Custom) return cost.interval; + return frequencyIntervalsInMonths[cost.frequency]; +} + +// ============================================================================ +// Core projection +// ============================================================================ + +export function buildCashflowProjection( + revenues: CashflowRevenue[], + costs: CashflowCost[], + settings: CashflowSettings, +): CashflowProjectionResult { + const monthKeys = generateMonthKeys(settings.startDate); + const endKey = monthKeys[monthKeys.length - 1]; + + // Pre-allocate monthly buckets + const revenueBuckets = new Map(); + const costBuckets = new Map(); + for (const key of monthKeys) { + revenueBuckets.set(key, 0); + costBuckets.set(key, 0); + } + + // ---- Distribute revenues into monthly buckets ---- + for (const rev of revenues) { + for (const installment of rev.installments) { + const key = toMonthKey(installment.date); + if (revenueBuckets.has(key) && new Date(installment.date) >= new Date(settings.startDate)) { + revenueBuckets.set(key, revenueBuckets.get(key)! + installment.amount); + } + } + } + + // ---- Distribute costs into monthly buckets ---- + for (const cost of costs) { + if (cost.frequency === Frequency.OneTime) { + // Single occurrence + const key = toMonthKey(new Date(cost.date)); + if (costBuckets.has(key) && new Date(cost.date) >= new Date(settings.startDate)) { + const adjusted = getAdjustedCostAmount(cost, key, settings); + costBuckets.set(key, costBuckets.get(key)! + adjusted); + } + } else { + // Recurring: expand occurrences within the projection window + const interval = getIntervalMonths(cost); + if (interval <= 0) continue; // safety guard + + // Parse as local date to avoid UTC shift + const [cy, cm] = cost.date.split("-").map(Number); + const cursor = new Date(cy, cm - 1, 1); + + // Advance past occurrences that fall before the exact start date + while (new Date(cost.date) < new Date(settings.startDate) && toMonthKey(cursor) < toMonthKey(new Date(cy, cm, 1))) { + cursor.setMonth(cursor.getMonth() + interval); + } + + while (toMonthKey(cursor) <= endKey) { + const key = toMonthKey(cursor); + if (costBuckets.has(key)) { + const adjusted = getAdjustedCostAmount(cost, key, settings); + costBuckets.set(key, costBuckets.get(key)! + adjusted); + } + cursor.setMonth(cursor.getMonth() + interval); + } + } + } + + // ---- Build chart data with running cash balance ---- + let balance = settings.startingCash; + let lowestBalance = balance; + let totalRevenue = 0; + let totalCosts = 0; + + const chartData: ChartDataPoint[] = monthKeys.map((key) => { + const rev = revenueBuckets.get(key)!; + const cost = costBuckets.get(key)!; + totalRevenue += rev; + totalCosts += cost; + balance += rev - cost; + + if (balance < lowestBalance) { + lowestBalance = balance; + } + + return { + month: toEndOfMonth(key), + cashBalance: round2(balance), + revenue: round2(rev), + costs: round2(cost), + }; + }); + + return { + chartData, + kpis: { + finalBalance: round2(balance), + lowestBalancePoint: round2(lowestBalance), + totalRevenue: round2(totalRevenue), + totalCosts: round2(totalCosts), + }, + }; +} + +// ============================================================================ +// Cost adjustment for salary / benefits annual increase +// ============================================================================ + +function getAdjustedCostAmount( + cost: CashflowCost, + currentMonthKey: string, + settings: CashflowSettings, +): number { + const costStartKey = toMonthKey(cost.date); + const years = yearsElapsed(costStartKey, currentMonthKey); + + if (cost.type === CostType.Salary && Number.isNaN(settings.salaryIncrease) === false) { + return cost.amount * Math.pow(1 + (settings.salaryIncrease/100), years); + } + if (cost.type === CostType.Benefits && Number.isNaN(settings.benefitsIncrease) === false) { + return cost.amount * Math.pow(1 + (settings.benefitsIncrease/100), years); + } + return cost.amount; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} diff --git a/frontend/src/main-page/dashboard/components/DateFilter.tsx b/frontend/src/main-page/dashboard/components/DateFilter.tsx index a5b0bd6e..1fc967d5 100644 --- a/frontend/src/main-page/dashboard/components/DateFilter.tsx +++ b/frontend/src/main-page/dashboard/components/DateFilter.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { updateYearFilter } from "../../../external/bcanSatchel/actions"; import { getAppStore } from "../../../external/bcanSatchel/store"; import { observer } from "mobx-react-lite"; @@ -6,10 +6,32 @@ import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import Button from "../../../components/Button"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import CheckboxField from "../../../components/CheckboxField"; + +function useOutsideClick(callback: () => void) { + const ref = useRef(null); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + // Check if the click happened outside the referenced element + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + }; + + document.addEventListener('mousedown', handleClick); + return () => { + document.removeEventListener('mousedown', handleClick); + }; + }, [callback]); + + return ref; +} const DateFilter: React.FC = observer(() => { const { allGrants, yearFilter } = getAppStore(); const [showDropdown, setShowDropdown] = useState(false); + const ref = useOutsideClick(() => setShowDropdown(false)); // Generate unique years dynamically from grants const uniqueYears = Array.from( @@ -62,10 +84,11 @@ const DateFilter: React.FC = observer(() => { onClick={() => setShowDropdown(!showDropdown)} logo={faChevronDown} logoPosition="right" - className="bg-white border-grey-500 inline-flex items-center justify-between text-sm lg:text-base" + className="bg-white border-grey-500 inline-flex items-center justify-between text-sm lg:text-base !min-w-60" />
    {uniqueYears.map((year) => ( -
  • -
    - handleCheckboxChange({ target: { value: year.toString(), checked: !selectedYears.includes(year) } } as React.ChangeEvent)} + label={
    {year.toString()}
    } /> - -
    -
  • ))}

diff --git a/frontend/src/main-page/dashboard/components/GanttYearGrantTimeline.tsx b/frontend/src/main-page/dashboard/components/GanttYearGrantTimeline.tsx index e0d84d8b..b29739f7 100644 --- a/frontend/src/main-page/dashboard/components/GanttYearGrantTimeline.tsx +++ b/frontend/src/main-page/dashboard/components/GanttYearGrantTimeline.tsx @@ -5,6 +5,7 @@ import { SetStateAction, useCallback, useState } from "react"; import { Grant } from "../../../../../middle-layer/types/Grant"; import { getColorStatus } from "../../../../../middle-layer/types/Status"; import "../styles/Dashboard.css"; +import { useNavigate } from "react-router-dom"; export const GanttYearGrantTimeline = observer( ({ @@ -16,6 +17,8 @@ export const GanttYearGrantTimeline = observer( grants: Grant[]; uniqueYears: number[]; }) => { + const navigate = useNavigate(); + // Filter grants for the max selected year // and if the current year is selected in the filter include that as well const filterYear = @@ -42,12 +45,12 @@ export const GanttYearGrantTimeline = observer( const startDate = new Date( application_deadline.getFullYear(), application_deadline.getMonth(), - application_deadline.getDate() - 14 + application_deadline.getDate() - 14, ); const endDate = new Date( application_deadline.getFullYear(), application_deadline.getMonth(), - application_deadline.getDate() + application_deadline.getDate(), ); // Create application task @@ -70,12 +73,12 @@ export const GanttYearGrantTimeline = observer( const report_startDate = new Date( report_deadline.getFullYear(), report_deadline.getMonth(), - report_deadline.getDate() - 14 + report_deadline.getDate() - 14, ); const report_endDate = new Date( report_deadline.getFullYear(), report_deadline.getMonth(), - report_deadline.getDate() + report_deadline.getDate(), ); tasks.push({ @@ -110,9 +113,17 @@ export const GanttYearGrantTimeline = observer( (range: SetStateAction<{ startDate: Date; endDate: Date }>) => { setRange(range); }, - [] + [], ); + const handleClick = (grantId: number ) => { + if (typeof grantId === "number") { + navigate("/main/all-grants", { + state: { selectedGrantId: grantId }, + }); + } + }; + // Filtering events that are included in current date range // Example can be also found on video https://youtu.be/9oy4rTVEfBQ?t=118&si=52BGKSIYz6bTZ7fx // and in the react-scheduler repo App.tsx file https://github.com/Bitnoise/react-scheduler/blob/master/src/App.tsx @@ -162,11 +173,13 @@ export const GanttYearGrantTimeline = observer( filterButtonState: -1, showTooltip: false, }} + onItemClick={(data) => handleClick(parseInt(data.id))} + onTileClick={(data) => handleClick(parseInt(data.id))} />
); - } + }, ); export default GanttYearGrantTimeline; diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 0886c9a9..e6b32890 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -20,13 +20,18 @@ import EditGrant from "./edit-grant/EditGrant.tsx"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { clearAllFilters } from "../../external/bcanSatchel/actions.ts"; import { getAppStore } from "../../external/bcanSatchel/store.ts"; +import { useLocation, useNavigate } from "react-router-dom"; function GrantPage() { const [showEditGrant, setShowEditGrant] = useState(false); + const location = useLocation(); + const navigate = useNavigate(); // Use ProcessGrantData reactively to get filtered grants const { grants } = ProcessGrantData(); const [curId, setCurId] = useState(null); + const selectedGrantId = + (location.state as { selectedGrantId?: number } | null)?.selectedGrantId; const curGrant = grants.find((g) => g.grantId === curId) ?? @@ -76,18 +81,35 @@ function GrantPage() { } }; - // Preserve current selection when still visible; otherwise show the first visible grant. useEffect(() => { if (grants.length === 0) { setCurId(null); return; } + if (typeof selectedGrantId === "number") { + const selectedGrantIsVisible = grants.some( + (grant) => grant.grantId === selectedGrantId, + ); + + if (!selectedGrantIsVisible) { + clearAllFilters(); + return; + } + + if (curId !== selectedGrantId) { + setCurId(selectedGrantId); + } + + navigate(location.pathname, { replace: true, state: null }); + return; + } + const currentSelectionStillVisible = grants.some((grant) => grant.grantId === curId); if (!currentSelectionStillVisible) { setCurId(grants[0].grantId); } - }, [curId, grants]); + }, [curId, grants, selectedGrantId, navigate, location.pathname]); return (
@@ -117,7 +139,7 @@ function GrantPage() { ))}
- +
) : (
No grants found. diff --git a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx index 776aab32..30944924 100644 --- a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx +++ b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx @@ -200,7 +200,7 @@ const EditGrant: React.FC<{ onClick={onClose} />
diff --git a/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx b/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx index 5c312e5f..6f3e3eb4 100644 --- a/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx +++ b/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx @@ -213,7 +213,7 @@ const AddContactPopup = observer(
diff --git a/frontend/src/main-page/grants/filter-bar/FilterBar.tsx b/frontend/src/main-page/grants/filter-bar/FilterBar.tsx index a3837fbb..d6a30c15 100644 --- a/frontend/src/main-page/grants/filter-bar/FilterBar.tsx +++ b/frontend/src/main-page/grants/filter-bar/FilterBar.tsx @@ -82,7 +82,9 @@ const FilterBar: React.FC = observer(() => { }; const handleStatusSelect = (status: Status) => { - const newSelected = filterStatus === status ? null : status; + const newSelected = filterStatus.includes(status) + ? filterStatus.filter((s) => s !== status) + : [...filterStatus, status]; updateFilter(newSelected); }; @@ -154,6 +156,10 @@ const FilterBar: React.FC = observer(() => { } }; + const handleStatusClearAll = () => { + updateFilter([]); + } + const dueDateActive = showDueDateCard || sort?.header === "application_deadline" || startDateFilter || endDateFilter; const amountActive = showAmountCard || sort?.header === "amount" || amountMinFilter !== null || amountMaxFilter !== null; @@ -278,11 +284,15 @@ const FilterBar: React.FC = observer(() => { logo={showStatusDropdown ? faChevronUp : faChevronDown} logoPosition="right" className={`bg-white text-sm lg:text-base whitespace-nowrap ${ - showStatusDropdown || filterStatus ? activeButtonClass : inactiveButtonClass + showStatusDropdown || filterStatus.length > 0 ? activeButtonClass : inactiveButtonClass }`} /> {showStatusDropdown && ( - + )}
diff --git a/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx b/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx index 8e7e8907..89e421c2 100644 --- a/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx +++ b/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx @@ -5,45 +5,44 @@ import { getColorStatus, } from "../../../../../../middle-layer/types/Status.ts"; import { observer } from "mobx-react-lite"; +import CheckboxField from "../../../../components/CheckboxField"; interface StatusDropdownProps { - selected: Status | null; + selected: Status[]; onSelect: (status: Status) => void; + onClearAll: () => void; } -const StatusDropdown: React.FC = observer(({ selected, onSelect }) => { +const StatusDropdown: React.FC = observer(({ selected, onSelect, onClearAll }) => { const statuses = Object.values(Status); return ( -
-
+
+
{statuses.map((status) => ( - - + id={`status-filter-${String(status)}`} + checked={selected.includes(status)} + onChange={() => onSelect(status)} + label={ + + {status} + + } + /> ))} +
); diff --git a/frontend/src/main-page/grants/filter-bar/grantFilters.ts b/frontend/src/main-page/grants/filter-bar/grantFilters.ts index d80a7b61..5ce60753 100644 --- a/frontend/src/main-page/grants/filter-bar/grantFilters.ts +++ b/frontend/src/main-page/grants/filter-bar/grantFilters.ts @@ -1,4 +1,5 @@ import { Grant } from "../../../../../middle-layer/types/Grant.ts"; +import { Status } from "../../../../../middle-layer/types/Status.ts"; import { User } from "../../../../../middle-layer/types/User.ts"; // filters grants by looping thru all filters @@ -7,9 +8,8 @@ export const filterGrants = ( predicates: ((grant: Grant) => boolean)[] ) => grants.filter((grant) => predicates.every((fn) => fn(grant))); -// for subheaders for a single status -export const statusFilter = (status: string | null) => (grant: Grant) => - !status || grant.status === status; +export const statusFilter = (statuses: Status[]) => (grant: Grant) => + !statuses.length || statuses.includes(grant.status); // TODO note: what attribute to filter by? currently doing application deadline // filter for calendar feature, if both null no filter, if 1 null, one-sided filter diff --git a/frontend/src/main-page/grants/grant-view/components/ContactCard.tsx b/frontend/src/main-page/grants/grant-view/components/ContactCard.tsx index 2d42464f..9493549d 100644 --- a/frontend/src/main-page/grants/grant-view/components/ContactCard.tsx +++ b/frontend/src/main-page/grants/grant-view/components/ContactCard.tsx @@ -2,16 +2,16 @@ import { getAppStore } from "../../../../external/bcanSatchel/store"; import POC from "../../../../../../middle-layer/types/POC"; import Avatar from "../../../../components/Avatar"; import logo from "../../../../images/logo.svg"; +import { observer } from "mobx-react-lite"; type ContactCardProps = { contact?: POC; type?: "BCAN" | "Granter"; }; -const store = getAppStore(); +const ContactCard = observer(({ contact, type }: ContactCardProps) => { + const store = getAppStore(); const activeUsers = store.activeUsers || []; - -export default function ContactCard({ contact, type }: ContactCardProps) { const contactPhoto = type === "BCAN" ? activeUsers.find((user) => user.email === contact?.POC_email) @@ -52,4 +52,6 @@ export default function ContactCard({ contact, type }: ContactCardProps) {
); -} +}); + +export default ContactCard; diff --git a/frontend/src/main-page/grants/grant-view/components/CostBenefitAnalysis.tsx b/frontend/src/main-page/grants/grant-view/components/CostBenefitAnalysis.tsx index a8a95d36..c2958766 100644 --- a/frontend/src/main-page/grants/grant-view/components/CostBenefitAnalysis.tsx +++ b/frontend/src/main-page/grants/grant-view/components/CostBenefitAnalysis.tsx @@ -20,27 +20,59 @@ export const CostBenefitAnalysis: React.FC = ({ }) => { const [hourlyRate, setHourlyRate] = useState(""); const [timePerReport, setTimePerReport] = useState(""); + const [inputErrors, setInputErrors] = useState<{ + hourlyRate?: string; + timePerReport?: string; + }>({}); const [costBenefitResult, setCostBenefitResult] = useState(null); + const validatePositiveNumber = ( + value: string, + fieldLabel: string, + ): string | null => { + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return `${fieldLabel} is required.`; + } + + // Require a full decimal number match to reject values like "2;0". + if (!/^(?:\d+\.?\d*|\.\d+)$/.test(trimmedValue)) { + return `${fieldLabel} must be a valid number.`; + } + + const parsedValue = Number(trimmedValue); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + return `${fieldLabel} must be greater than 0.`; + } + + return null; + }; + const calculateCostBenefit = () => { - console.log("Called calculate"); - console.log("hourlyRate state:", hourlyRate); - console.log("timePerReport state:", timePerReport); - const rate = parseFloat(hourlyRate); - const timeReport = parseFloat(timePerReport); - - console.log("Parsed rate:", rate); - console.log("Parsed timeReport:", timeReport); - - // Validation - if (isNaN(rate) || isNaN(timeReport) || rate <= 0 || timeReport <= 0) { - alert( - "Please enter valid positive numbers for hourly rate and time per report.", - ); + const hourlyRateError = validatePositiveNumber(hourlyRate, "Hourly rate"); + const timePerReportError = validatePositiveNumber( + timePerReport, + "Time per report", + ); + + const nextErrors = { + hourlyRate: hourlyRateError ?? undefined, + timePerReport: timePerReportError ?? undefined, + }; + + setInputErrors(nextErrors); + + if (hourlyRateError || timePerReportError) { + setCostBenefitResult(null); return; } + const rate = Number(hourlyRate.trim()); + const timeReport = Number(timePerReport.trim()); + const reportCount = grant.report_deadlines?.length ?? 0; const grantAmount = grant.amount; const estimatedTime = grant.estimated_completion_time | 5; @@ -88,8 +120,18 @@ export const CostBenefitAnalysis: React.FC = ({ label="Hourly Rate" placeholder="Enter rate" value={hourlyRate} - onChange={(e) => setHourlyRate(e.target.value)} + inputMode="decimal" + error={Boolean(inputErrors.hourlyRate)} + onChange={(e) => { + setHourlyRate(e.target.value); + if (inputErrors.hourlyRate) { + setInputErrors((previous) => ({ ...previous, hourlyRate: undefined })); + } + }} /> + {inputErrors.hourlyRate && ( +

{inputErrors.hourlyRate}

+ )} {/* Time Per Report Input */} @@ -99,8 +141,18 @@ export const CostBenefitAnalysis: React.FC = ({ label="Time Per Report (hours)" placeholder="Enter time" value={timePerReport} - onChange={(e) => setTimePerReport(e.target.value)} + inputMode="decimal" + error={Boolean(inputErrors.timePerReport)} + onChange={(e) => { + setTimePerReport(e.target.value); + if (inputErrors.timePerReport) { + setInputErrors((previous) => ({ ...previous, timePerReport: undefined })); + } + }} /> + {inputErrors.timePerReport && ( +

{inputErrors.timePerReport}

+ )} {/* Calculate Button */} diff --git a/frontend/src/main-page/grants/grant-view/components/StatusIndicator.tsx b/frontend/src/main-page/grants/grant-view/components/StatusIndicator.tsx index 61c53c3a..19754275 100644 --- a/frontend/src/main-page/grants/grant-view/components/StatusIndicator.tsx +++ b/frontend/src/main-page/grants/grant-view/components/StatusIndicator.tsx @@ -25,7 +25,7 @@ const StatusIndicator: React.FC = ({ curStatus, onClick, a // text-gray-700 px-3 py-1 text-sm border-2 ${status === btn.id ? "bg-primary-800 border-primary-800" : "border-grey-300 > - {labelText} + {labelText} ); }; diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx index b8b91906..975fbacf 100644 --- a/frontend/src/main-page/navbar/NavBar.tsx +++ b/frontend/src/main-page/navbar/NavBar.tsx @@ -12,7 +12,6 @@ import { UserStatus } from "../../../../middle-layer/types/UserStatus"; import NavTab, { NavTabProps } from "./NavTab.tsx"; import { faChartLine, faMoneyBill, faClipboardCheck } from "@fortawesome/free-solid-svg-icons"; import { NavBarBranding } from "../../translations/general.ts"; -import { saveCashflowSettings } from "../cash-flow/processCashflowDataEditSave"; import ActionConfirmation from "../../components/ActionConfirmation"; const tabs: NavTabProps[] = [ @@ -30,10 +29,6 @@ const NavBar: React.FC = observer(() => { const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false); const performLogout = async () => { - const { cashflowSettings } = getAppStore(); - if (cashflowSettings) { - await saveCashflowSettings(cashflowSettings); - } logoutUser(); clearAllFilters(); navigate("/login"); @@ -50,7 +45,7 @@ const NavBar: React.FC = observer(() => { title="Sign Out" subtitle="Are you sure you want to" boldSubtitle="sign out" - warningMessage="Your cash flow settings will be saved to the server, then you will be logged out." + warningMessage="You will be logged out of your account." variant="update" /> {/* Logo at top */} diff --git a/frontend/src/main-page/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index cdbd1df4..0fd130aa 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -5,11 +5,11 @@ import InfoCard from "./components/InfoCard"; import Avatar from "../../components/Avatar"; import logo from "../../images/logo.svg"; import { faPenToSquare } from "@fortawesome/free-solid-svg-icons"; -import ProfilePictureModal from "./ProfilePictureModal"; +import ProfilePictureModal from "./components/ProfilePictureModal"; import { ALLOWED_PROFILE_PIC_EXTENSIONS, MAX_PROFILE_PIC_SIZE_MB } from "./profilePictureConstants"; import { removeProfilePic } from "../../external/bcanSatchel/actions"; import {api} from "../../api" -import ChangePasswordModal, { ChangePasswordFormValues } from "./ChangePasswordModal"; +import ChangePasswordModal, { ChangePasswordFormValues } from "./components/ChangePasswordModal"; import { getAppStore } from "../../external/bcanSatchel/store"; import { setActiveUsers, updateUserProfile } from "../../external/bcanSatchel/actions"; import { User } from "../../../../middle-layer/types/User"; @@ -36,6 +36,7 @@ function Settings() { const [isRemoveProfilePicModalOpen, setIsRemoveProfilePicModalOpen] = useState(false); const [isSaveProfileModalOpen, setIsSaveProfileModalOpen] = useState(false); const [profilePictureMessage, setProfilePictureMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); const isEmailChanged = editForm.email.trim().toLowerCase() !== (store.user?.email ?? "").trim().toLowerCase(); @@ -74,6 +75,7 @@ function Settings() { }; const handleSaveEdit = async () => { + setIsSubmitting(true); if (!EMAIL_REGEX.test(editForm.email)) { setPersonalInfoError("Email is not valid."); return; @@ -118,6 +120,8 @@ function Settings() { console.error("Error updating profile:", error); setPersonalInfoError("An unexpected error occurred. Please try again."); } + + setIsSubmitting(false); }; const handleRemoveProfilePic = async () => { @@ -305,8 +309,9 @@ function Settings() { className="bg-white text-gray-600 border-2 border-grey-500" />