From de8fa47cbd4d12c2e00df5f0241d7dba33d80166 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 01:18:30 -0400 Subject: [PATCH 01/68] Adding new field to types --- .../__test__/cashflow-cost.service.spec.ts | 66 +++++++++++++++++++ backend/src/cost/cashflow-cost.service.ts | 9 +++ backend/src/cost/types/cost.types.ts | 5 ++ middle-layer/types/CashflowCost.ts | 1 + middle-layer/types/Frequency.ts | 6 +- 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/backend/src/cost/__test__/cashflow-cost.service.spec.ts b/backend/src/cost/__test__/cashflow-cost.service.spec.ts index fee3fe1d..efdffb98 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: 12, }; 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, }); @@ -571,6 +627,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }); @@ -592,6 +649,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(NotFoundException); @@ -601,6 +659,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 +681,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(ConflictException); @@ -631,6 +691,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 +713,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(NotFoundException); @@ -661,6 +723,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 +741,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22' as TDateISO, }), ).rejects.toThrow(InternalServerErrorException); @@ -687,6 +751,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 +766,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/middle-layer/types/CashflowCost.ts b/middle-layer/types/CashflowCost.ts index 6417cec8..23508086 100644 --- a/middle-layer/types/CashflowCost.ts +++ b/middle-layer/types/CashflowCost.ts @@ -9,4 +9,5 @@ export interface CashflowCost { type: CostType; date: TDateISO; frequency: Frequency; + interval: number; // Only applicable for recurring costs, represents the number of months between each occurrence } \ No newline at end of file diff --git a/middle-layer/types/Frequency.ts b/middle-layer/types/Frequency.ts index 6722db2a..ad3dae74 100644 --- a/middle-layer/types/Frequency.ts +++ b/middle-layer/types/Frequency.ts @@ -3,13 +3,15 @@ export enum Frequency { Yearly = "Yearly", Monthly = "Monthly", - OneTime = "One Time" + OneTime = "One Time", + Recurring = "Recurring" } export const frequencyLabels = [ { value: Frequency.Yearly, label: "/year" }, { value: Frequency.Monthly, label: "/month" }, - { value: Frequency.OneTime, label: "" } + { value: Frequency.OneTime, label: "" }, + { value: Frequency.Recurring, label: " recurring" } ]; export function formatDateByFrequency( From 5baa09bdc18a7724633fd105f5fe93fa798dd12c Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 01:35:15 -0400 Subject: [PATCH 02/68] Updating add/edit cost for interval --- .../cash-flow/components/CashAddEditCost.tsx | 34 ++++++++++++++++++- .../cash-flow/components/CashflowKPICard.tsx | 2 +- middle-layer/types/Frequency.ts | 11 ++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index ccc09cda..8fd9a4cd 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -5,7 +5,7 @@ import Button from "../../../components/Button"; import InputField from "../../../components/InputField"; import CashCategoryDropdown from "./CashCategoryDropdown"; import { createNewCost, saveCostEdits } from "../processCashflowDataEditSave"; -import { Frequency } from "../../../../../middle-layer/types/Frequency"; +import { Frequency, frequencyIntervalsInMonths } from "../../../../../middle-layer/types/Frequency"; import { TDateISO } from "../../../../../backend/src/utils/date"; import { getAppStore } from "../../../external/bcanSatchel/store"; @@ -16,6 +16,7 @@ type FieldErrors = { date?: string; amount?: string; submit?: string; + interval?: string; }; type CashEditCostProps = { @@ -39,6 +40,9 @@ export default function CashAddEditCost({ const [amount, setAmount] = useState( costItem ? costItem.amount : null, ); + const [interval, setInterval] = useState( + costItem ? costItem.interval : null, + ); const [date, setDate] = useState( costItem ? costItem.date : null, ); @@ -66,6 +70,10 @@ export default function CashAddEditCost({ nextErrors.frequency = "Please select a frequency."; } + if (frequency === Frequency.Custom && (interval === null || interval <= 0 || !Number.isFinite(interval))) { + nextErrors.interval = "Please enter a valid interval."; + } + if (!date) { nextErrors.date = "Please select a start date."; } @@ -84,6 +92,7 @@ export default function CashAddEditCost({ Object.keys(nextErrors).length > 0 || !type || !frequency || + (frequency === Frequency.Custom && (interval === null || interval <= 0 || !Number.isFinite(interval))) || !date || amount === null ) { @@ -95,6 +104,7 @@ export default function CashAddEditCost({ type, amount, frequency, + interval: frequency === Frequency.Custom ? interval ?? 0 : frequencyIntervalsInMonths[frequency], date, }; }; @@ -104,6 +114,7 @@ export default function CashAddEditCost({ setFrequency(null); setCostName(""); setAmount(null); + setInterval(null); setDate(null); setErrors({}); }; @@ -211,6 +222,27 @@ export default function CashAddEditCost({ ) : null} + {frequency === Frequency.Custom && + (
+ + setInterval( + event.target.value === "" ? null : Number(event.target.value), + ) + } + /> + {errors.interval ? ( +

{errors.interval}

+ ) : null} +
) + }
{value}
diff --git a/middle-layer/types/Frequency.ts b/middle-layer/types/Frequency.ts index ad3dae74..cfcccbed 100644 --- a/middle-layer/types/Frequency.ts +++ b/middle-layer/types/Frequency.ts @@ -4,16 +4,23 @@ export enum Frequency { Yearly = "Yearly", Monthly = "Monthly", OneTime = "One Time", - Recurring = "Recurring" + Custom = "Custom" } export const frequencyLabels = [ { value: Frequency.Yearly, label: "/year" }, { value: Frequency.Monthly, label: "/month" }, { value: Frequency.OneTime, label: "" }, - { value: Frequency.Recurring, label: " recurring" } + { value: Frequency.Custom, label: " recurring" } ]; +export const frequencyIntervalsInMonths: Record = { + [Frequency.Yearly]: 12, + [Frequency.Monthly]: 1, + [Frequency.OneTime]: 0, + [Frequency.Custom]: 0, // Interval is determined by the user for recurring costs +}; + export function formatDateByFrequency( date: string | Date, frequency: Frequency From 191212f8ed8092f595cc0b2fc9438eae7e703e7e Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 01:39:45 -0400 Subject: [PATCH 03/68] Cashflow cards styling --- .../src/main-page/cash-flow/components/CashflowKPICard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashflowKPICard.tsx b/frontend/src/main-page/cash-flow/components/CashflowKPICard.tsx index c2c1f04b..e5998cde 100644 --- a/frontend/src/main-page/cash-flow/components/CashflowKPICard.tsx +++ b/frontend/src/main-page/cash-flow/components/CashflowKPICard.tsx @@ -17,7 +17,7 @@ export default function CashflowKPICard({ size, }: CardProps) { return ( -
+
@@ -29,7 +29,7 @@ export default function CashflowKPICard({ />
{value}
From 28a289a79049a67fe726f15ca6ca7009ba032126 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 01:47:51 -0400 Subject: [PATCH 04/68] Styling changes --- frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx | 2 +- frontend/src/main-page/cash-flow/components/CashPosition.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index 8fd9a4cd..e4d91846 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -227,7 +227,7 @@ export default function CashAddEditCost({ { Date: Tue, 14 Apr 2026 10:38:44 -0400 Subject: [PATCH 05/68] Initial ts script --- frontend/src/external/bcanSatchel/store.ts | 1 + .../src/main-page/cash-flow/CashFlowPage.tsx | 28 ++- .../cash-flow/components/CashProjection.tsx | 29 ++- .../components/CashProjectionChart.tsx | 36 +-- .../src/main-page/cash-flow/projection.ts | 225 ++++++++++++++++++ 5 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 frontend/src/main-page/cash-flow/projection.ts diff --git a/frontend/src/external/bcanSatchel/store.ts b/frontend/src/external/bcanSatchel/store.ts index cd64a805..2478cf99 100644 --- a/frontend/src/external/bcanSatchel/store.ts +++ b/frontend/src/external/bcanSatchel/store.ts @@ -6,6 +6,7 @@ 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 { TDateISO } from '../../../../backend/src/utils/date'; export interface AppState { isAuthenticated: boolean; diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index 6625735a..d2b19332 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -12,6 +12,7 @@ import CashProjection from "./components/CashProjection"; import CashSourceList from "./components/CashSourceList"; import { ProcessCashflowData } from "./processCashflowData"; import CashCreateLineItem from "./components/CashCreateLineItem"; +import { TDateISO } from "../../../../backend/src/utils/date"; export const formatMoney = (amount: number) => { return amount.toLocaleString("en-US", { @@ -36,13 +37,23 @@ const CashFlowPage = observer(() => { /> accumulator + currentValue.amount, 0))} + value={formatMoney( + revenues.reduce( + (accumulator, currentValue) => accumulator + currentValue.amount, + 0, + ), + )} logo={faArrowTrendUp} className="text-green" /> accumulator + currentValue.amount, 0)/12)} + value={formatMoney( + costs.reduce( + (accumulator, currentValue) => accumulator + currentValue.amount, + 0, + ) / 12, + )} logo={faUserGroup} className="text-primary" /> @@ -60,7 +71,18 @@ const CashFlowPage = observer(() => { {/* Row 3 */} - + {/* Row 4 */} diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index 0efe352b..9567b5a9 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -1,20 +1,37 @@ import { CashflowCost } from "../../../../../middle-layer/types/CashflowCost"; import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; +import { CashflowSettings } from "../../../../../middle-layer/types/CashflowSettings"; +import { buildCashflowProjection } from "../projection"; import CashProjectionChart from "./CashProjectionChart"; type ProjectionProps = { costs: CashflowCost[]; revenues: CashflowRevenue[]; + settings: CashflowSettings; }; -export default function CashProjection({costs, revenues}:ProjectionProps) { +export default function CashProjection({ + costs, + revenues, + settings, +}: ProjectionProps) { + // replace with actual data, filter for 36 months + const { chartData, kpis } = buildCashflowProjection( + revenues, + costs, + settings, + ); // 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 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: "36-Mo Costs", value: kpis.totalCosts, color: "text-primary" }, ]; return ( @@ -22,7 +39,7 @@ export default function CashProjection({costs, revenues}:ProjectionProps) {
{"36-Month Cash Flow Projection"}
- +
{cards.map((c) => (
{ - - // 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, - }, - ]; - +const CashProjectionChart = observer(({ data }: ProjectionProps) => { // Sort by date to ensure correct line order - data.sort((a, b) => a.date.getTime() - b.date.getTime()); + data.sort( + (a, b) => new Date(a.month).getTime() - new Date(b.month).getTime(), + ); return (
@@ -83,7 +65,9 @@ const CashProjectionChart = observer(({}: ChartProps) => { scale="time" dy={10} style={{ fontSize: "var(--font-size-sm)" }} - tickFormatter={(date: Date) => date.getMonth().toLocaleString() + "/" + date.getFullYear()} + tickFormatter={(date: Date) => + date.getMonth().toLocaleString() + "/" + date.getFullYear() + } axisLine={false} tickLine={false} /> 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..2e119b69 --- /dev/null +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -0,0 +1,225 @@ +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 { + /** Label shown on the x-axis, e.g. "Jan 2025" */ + month: string; + /** 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 zero-indexed month key like "2025-01" from a Date */ +function toMonthKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + return `${y}-${m}`; +} + +/** Human-readable label for x-axis */ +function toMonthLabel(key: string): string { + const [y, m] = key.split("-"); + const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${monthNames[parseInt(m, 10) - 1]} ${y}`; +} + +/** Generate an ordered list of 36 month keys starting from startDate */ +function generateMonthKeys(startDate: TDateISO): string[] { + const d = new Date(startDate); + // Normalize to first of the month + d.setDate(1); + const keys: string[] = []; + for (let i = 0; i < PROJECTION_MONTHS; i++) { + keys.push(toMonthKey(d)); + d.setMonth(d.getMonth() + 1); + } + 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 startKey = monthKeys[0]; + 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)) { + 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)) { + const adjusted = getAdjustedCostAmount(cost, key, startKey, 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 + + const costDate = new Date(cost.date); + costDate.setDate(1); + const cursor = new Date(costDate); + + // If the cost starts before the projection, advance to the first + // occurrence that falls within the window. + while (toMonthKey(cursor) < startKey) { + cursor.setMonth(cursor.getMonth() + interval); + } + + while (toMonthKey(cursor) <= endKey) { + const key = toMonthKey(cursor); + if (costBuckets.has(key)) { + const adjusted = getAdjustedCostAmount(cost, key, startKey, 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 lowestMonth = toMonthLabel(monthKeys[0]); + 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; + lowestMonth = toMonthLabel(key); + } + + return { + month: toMonthLabel(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, + projectionStartKey: string, + settings: CashflowSettings, +): number { + const years = yearsElapsed(projectionStartKey, currentMonthKey); + + if (cost.type === CostType.Salary) { + return cost.amount * Math.pow(1 + settings.salaryIncrease, years); + } + if (cost.type === CostType.Benefits) { + return cost.amount * Math.pow(1 + settings.benefitsIncrease, years); + } + return cost.amount; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} From c8b5b6d08e3e6601df9d5b54fbd5b6975ac455eb Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 16:48:12 -0400 Subject: [PATCH 06/68] Working on chart --- .../src/main-page/cash-flow/CashFlowPage.tsx | 25 +++---- .../cash-flow/components/CashProjection.tsx | 24 ++----- .../components/CashProjectionChart.tsx | 25 +++---- .../cash-flow/components/CashSourceList.tsx | 2 +- .../src/main-page/cash-flow/projection.ts | 71 ++++++++----------- middle-layer/types/Frequency.ts | 36 +++------- 6 files changed, 69 insertions(+), 114 deletions(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index d2b19332..d8db5a6e 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -13,6 +13,7 @@ import CashSourceList from "./components/CashSourceList"; import { ProcessCashflowData } from "./processCashflowData"; import CashCreateLineItem from "./components/CashCreateLineItem"; import { TDateISO } from "../../../../backend/src/utils/date"; +import { buildCashflowProjection } from "./projection"; export const formatMoney = (amount: number) => { return amount.toLocaleString("en-US", { @@ -25,6 +26,17 @@ export const formatMoney = (amount: number) => { const CashFlowPage = observer(() => { const { costs, revenues, cashflowSettings } = ProcessCashflowData(); + const { chartData, kpis } = buildCashflowProjection( + revenues, + costs, + cashflowSettings || { + startingCash: 0, + salaryIncrease: 0, + benefitsIncrease: 0, + startDate: new Date().toISOString().split("T")[0] as TDateISO, + }, + ); + return (
@@ -71,18 +83,7 @@ const CashFlowPage = observer(() => { {/* Row 3 */} - + {/* Row 4 */} diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index 9567b5a9..c4f7c3e9 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -1,26 +1,14 @@ -import { CashflowCost } from "../../../../../middle-layer/types/CashflowCost"; -import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; -import { CashflowSettings } from "../../../../../middle-layer/types/CashflowSettings"; -import { buildCashflowProjection } from "../projection"; +import { CashflowKPIs, ChartDataPoint } from "../projection"; import CashProjectionChart from "./CashProjectionChart"; type ProjectionProps = { - costs: CashflowCost[]; - revenues: CashflowRevenue[]; - settings: CashflowSettings; + data: ChartDataPoint[]; + kpis: CashflowKPIs; }; export default function CashProjection({ - costs, - revenues, - settings, + data, kpis }: ProjectionProps) { - // replace with actual data, filter for 36 months - const { chartData, kpis } = buildCashflowProjection( - revenues, - costs, - settings, - ); // replace with actual data const cards = [ @@ -31,7 +19,7 @@ export default function CashProjection({ color: "text-grey", }, { field: "Total Revenue", value: kpis.totalRevenue, color: "text-green" }, - { field: "36-Mo Costs", value: kpis.totalCosts, color: "text-primary" }, + { field: "Total Costs", value: kpis.totalCosts, color: "text-primary" }, ]; return ( @@ -39,7 +27,7 @@ export default function CashProjection({
{"36-Month Cash Flow Projection"}
- +
{cards.map((c) => (
{ - // Sort by date to ensure correct line order - data.sort( - (a, b) => new Date(a.month).getTime() - new Date(b.month).getTime(), - ); +const formatMonthYear = (timestamp: number): string => { + const d = new Date(timestamp); + return `${d.getMonth() + 1}/${d.getFullYear()}`; +}; +const CashProjectionChart = observer(({ data }: ProjectionProps) => { return (
@@ -35,7 +35,7 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { /> { name="Costs" /> - date.getMonth().toLocaleString() + "/" + date.getFullYear() - } + tickFormatter={formatMonthYear} axisLine={false} tickLine={false} /> @@ -75,7 +73,6 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { `$${(v / 1000).toFixed(0)}k`} @@ -88,9 +85,7 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { border: "1px solid lightgray", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", }} - labelFormatter={(date: Date) => - date.getMonth().toLocaleString() + "/" + date.getFullYear() - } + labelFormatter={formatMonthYear} formatter={(value: number) => `$${value.toLocaleString()}`} /> diff --git a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index 296a3307..be672849 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -43,7 +43,7 @@ export default function CashSourceList({ type, lineItems }: SourceProps) { {formatMoney(item.amount)}{frequencyLabels.find( (label) => label.value === (item as CashflowCost).frequency, )?.label} - {" on "} + {" "} {formatDateByFrequency((item as CashflowCost).date, (item as CashflowCost).frequency)}
)} diff --git a/frontend/src/main-page/cash-flow/projection.ts b/frontend/src/main-page/cash-flow/projection.ts index 2e119b69..7a367186 100644 --- a/frontend/src/main-page/cash-flow/projection.ts +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -10,8 +10,8 @@ import { /** One data point per month for the Recharts line chart */ export interface ChartDataPoint { - /** Label shown on the x-axis, e.g. "Jan 2025" */ - month: string; + /** 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 */ @@ -38,42 +38,33 @@ export interface CashflowProjectionResult { const PROJECTION_MONTHS = 36; -/** Returns a zero-indexed month key like "2025-01" from a Date */ -function toMonthKey(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); +/** 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}`; } -/** Human-readable label for x-axis */ -function toMonthLabel(key: string): string { - const [y, m] = key.split("-"); - const monthNames = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${monthNames[parseInt(m, 10) - 1]} ${y}`; +/** 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 d = new Date(startDate); - // Normalize to first of the month - d.setDate(1); + const [y, m] = startDate.split("-").map(Number); const keys: string[] = []; for (let i = 0; i < PROJECTION_MONTHS; i++) { - keys.push(toMonthKey(d)); - d.setMonth(d.getMonth() + 1); + const date = new Date(y, m - 1 + i, 1); // local date, no UTC shift + keys.push(toMonthKey(date)); } return keys; } @@ -132,7 +123,7 @@ export function buildCashflowProjection( // Single occurrence const key = toMonthKey(new Date(cost.date)); if (costBuckets.has(key)) { - const adjusted = getAdjustedCostAmount(cost, key, startKey, settings); + const adjusted = getAdjustedCostAmount(cost, key, settings); costBuckets.set(key, costBuckets.get(key)! + adjusted); } } else { @@ -140,9 +131,9 @@ export function buildCashflowProjection( const interval = getIntervalMonths(cost); if (interval <= 0) continue; // safety guard - const costDate = new Date(cost.date); - costDate.setDate(1); - const cursor = new Date(costDate); + // Parse as local date to avoid UTC shift + const [cy, cm] = cost.date.split("-").map(Number); + const cursor = new Date(cy, cm - 1, 1); // If the cost starts before the projection, advance to the first // occurrence that falls within the window. @@ -153,7 +144,7 @@ export function buildCashflowProjection( while (toMonthKey(cursor) <= endKey) { const key = toMonthKey(cursor); if (costBuckets.has(key)) { - const adjusted = getAdjustedCostAmount(cost, key, startKey, settings); + const adjusted = getAdjustedCostAmount(cost, key, settings); costBuckets.set(key, costBuckets.get(key)! + adjusted); } cursor.setMonth(cursor.getMonth() + interval); @@ -164,7 +155,6 @@ export function buildCashflowProjection( // ---- Build chart data with running cash balance ---- let balance = settings.startingCash; let lowestBalance = balance; - let lowestMonth = toMonthLabel(monthKeys[0]); let totalRevenue = 0; let totalCosts = 0; @@ -177,11 +167,10 @@ export function buildCashflowProjection( if (balance < lowestBalance) { lowestBalance = balance; - lowestMonth = toMonthLabel(key); } return { - month: toMonthLabel(key), + month: toEndOfMonth(key), cashBalance: round2(balance), revenue: round2(rev), costs: round2(cost), @@ -206,16 +195,16 @@ export function buildCashflowProjection( function getAdjustedCostAmount( cost: CashflowCost, currentMonthKey: string, - projectionStartKey: string, settings: CashflowSettings, ): number { - const years = yearsElapsed(projectionStartKey, currentMonthKey); + const costStartKey = toMonthKey(cost.date); + const years = yearsElapsed(costStartKey, currentMonthKey); if (cost.type === CostType.Salary) { - return cost.amount * Math.pow(1 + settings.salaryIncrease, years); + return cost.amount * Math.pow(1 + (settings.salaryIncrease/100), years); } if (cost.type === CostType.Benefits) { - return cost.amount * Math.pow(1 + settings.benefitsIncrease, years); + return cost.amount * Math.pow(1 + (settings.benefitsIncrease/100), years); } return cost.amount; } diff --git a/middle-layer/types/Frequency.ts b/middle-layer/types/Frequency.ts index cfcccbed..a16369a6 100644 --- a/middle-layer/types/Frequency.ts +++ b/middle-layer/types/Frequency.ts @@ -30,37 +30,19 @@ export function formatDateByFrequency( if (isNaN(parsedDate.getTime())) return ""; switch (frequency) { - case Frequency.Yearly: - return new Intl.DateTimeFormat("en-US", { - month: "short", - }).format(parsedDate) + " " + getOrdinal(parsedDate.getDate()); case Frequency.OneTime: - return new Intl.DateTimeFormat("en-US", { - month: "numeric", - day: "numeric", - year: "numeric", + return "on " + new Intl.DateTimeFormat("en-US", { + month: "2-digit", + day: "2-digit", + year: "2-digit", }).format(parsedDate); - case Frequency.Monthly: - return `the ${getOrdinal(parsedDate.getDate())}`; - default: - return ""; - } -} - -function getOrdinal(day: number): string { - if (day >= 11 && day <= 13) return `${day}th`; - - switch (day % 10) { - case 1: - return `${day}st`; - case 2: - return `${day}nd`; - case 3: - return `${day}rd`; - default: - return `${day}th`; + return "starting " + new Intl.DateTimeFormat("en-US", { + month: "2-digit", + day: "2-digit", + year: "2-digit", + }).format(parsedDate); } } \ No newline at end of file From 5c93faa8741da74b6e629441f3243a1327a117a6 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 16:56:45 -0400 Subject: [PATCH 07/68] Styling and empty state --- frontend/src/main-page/cash-flow/CashFlowPage.tsx | 4 ++-- .../cash-flow/components/CashProjection.tsx | 2 +- .../cash-flow/components/CashProjectionChart.tsx | 13 +++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index d8db5a6e..eda0a1b7 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -86,8 +86,8 @@ const CashFlowPage = observer(() => { {/* Row 4 */} - - + {revenues.length > 0 && } + {costs.length > 0 && }
); diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index c4f7c3e9..8ebc4e65 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -34,7 +34,7 @@ export default function CashProjection({ key={c.field} className="bg-grey-150 rounded px-2 py-2 min-w-0 flex-1 flex flex-col h-full text-sm lg:text-base" > -
{c.field}
+
{c.field}
{c.value.toLocaleString("en-US", { style: "currency", diff --git a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx index f8fda783..87206ecf 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx @@ -37,8 +37,8 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { type="monotone" dataKey="cashBalance" stroke="var(--color-blue)" - strokeWidth={2} - dot={{ r: 4 }} + strokeWidth={1.5} + dot={{ r: 2.5 }} name="Cash Balance" /> @@ -46,16 +46,16 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { type="monotone" dataKey="revenue" stroke="var(--color-green)" - strokeWidth={2} - dot={{ r: 4 }} + strokeWidth={1.5} + dot={{ r: 2.5 }} name="Revenue" /> { backgroundColor: "white", border: "1px solid lightgray", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", + textAlign: "left", }} labelFormatter={formatMonthYear} formatter={(value: number) => `$${value.toLocaleString()}`} From f7e9a61d934e878f76a3478d8b9bf73efb7d2aaf Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 17:01:07 -0400 Subject: [PATCH 08/68] Changing title name --- .../main-page/cash-flow/components/CashProjection.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index 8ebc4e65..5e4d50a2 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -6,13 +6,14 @@ type ProjectionProps = { kpis: CashflowKPIs; }; -export default function CashProjection({ - data, kpis -}: ProjectionProps) { - +export default function CashProjection({ data, kpis }: ProjectionProps) { // replace with actual data const cards = [ - { field: "Final Balance", value: kpis.finalBalance, color: "text-blue" }, + { + field: "Final ProjectedBalance", + value: kpis.finalBalance, + color: "text-blue", + }, { field: "Lowest Point", value: kpis.lowestBalancePoint, From 9df9895d4b867c778114268d4538ed0eaf2d895d Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 17:01:25 -0400 Subject: [PATCH 09/68] Spacing fix --- frontend/src/main-page/cash-flow/components/CashProjection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index 5e4d50a2..e5f38640 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -10,7 +10,7 @@ export default function CashProjection({ data, kpis }: ProjectionProps) { // replace with actual data const cards = [ { - field: "Final ProjectedBalance", + field: "Final Projected Balance", value: kpis.finalBalance, color: "text-blue", }, From 1af815bd4019674f88b65fda446adb54fab0cf5a Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 19:23:28 -0400 Subject: [PATCH 10/68] Squashed commit of the following: commit bd405053a2c0f4efbd456e156a46d9e8c190cf68 Merge: 928ad1f e08b3fd Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Tue Apr 14 18:46:04 2026 -0400 Merge pull request #414 from Code-4-Community/backgroundFix background fix commit e08b3fd7788ec5d67b1e4711d298aca6493b5363 Merge: 6ca3482 10fb2cd Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Mon Apr 13 13:24:48 2026 -0400 Merge branch 'main' into backgroundFix commit 6ca3482dfaf4058976376ba63853f2440bb7c21f Author: adityapat24 Date: Sun Apr 12 17:22:07 2026 -0400 removed media query commit 36c4cb3d7ae9b5f524a16f5500925b0381b80d02 Author: adityapat24 Date: Sun Apr 12 13:59:18 2026 -0400 background fix --- frontend/src/styles/index.css | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 51e946ea..c72dc773 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -125,19 +125,6 @@ textarea { border-color: var(--color-primary-900); } -@media (prefers-color-scheme: light) { - :root { - color: var(--color-grey-800); - background-color: white; - } - a:hover { - color: var(--color-secondary-500); - } - button { - background-color: var(--color-grey-100); - } -} - input { border-width: 2px; background-color: white; From 67f240e05b12796aa731351bb05634eb201680fe Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 19:27:39 -0400 Subject: [PATCH 11/68] Squashed commit of the following: commit bba66a702515b8a30604533582a4628704a2b41a Author: lyannne Date: Mon Apr 13 22:57:15 2026 -0400 resolving copilot comments commit 3eb6ad36c7400af76ef3a0b2d96a1fa293b95ae9 Author: Lyanne Xu Date: Mon Apr 13 22:18:52 2026 -0400 add revenue and cost to clear all filters Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> commit 52369176552867fbde59d3544a7009bdd280f45c Author: Lyanne Xu Date: Mon Apr 13 22:17:14 2026 -0400 stronger typing for multi select status filter Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> commit 8becfe933b2ad5b4c6d33d7898c09923c797d580 Author: lyannne Date: Mon Apr 13 21:46:28 2026 -0400 same button behavior for grant page filters and categoryfilter commit d1bc1083b690d14b24b2c3d2351419a8b1df2e05 Merge: 46efaf0 928ad1f Author: lyannne Date: Mon Apr 13 21:45:07 2026 -0400 Merge remote-tracking branch 'origin/main' into surprise-ben-filtering-for-cashflow commit 46efaf0b1fab9a10265858cae99103cce4efceb2 Author: lyannne Date: Mon Apr 13 21:38:06 2026 -0400 custom checkbox field to match design, edits made to cashcategorydropdwon to match design, misc css changes commit 9b351c0959f09632ab1a738f17c60b7abb778050 Author: lyannne Date: Mon Apr 13 17:36:59 2026 -0400 grant sttaus and cashflow revenues and costs have multiselect filter, edited dropdown to match design, misc. css fixes --- frontend/src/components/CheckboxField.tsx | 59 +++++++++ frontend/src/components/InputField.tsx | 6 +- frontend/src/external/bcanSatchel/actions.ts | 14 +- frontend/src/external/bcanSatchel/mutators.ts | 19 ++- frontend/src/external/bcanSatchel/store.ts | 11 +- .../cash-flow/components/CashAddEditCost.tsx | 6 +- .../cash-flow/components/CashAddRevenue.tsx | 3 +- .../components/CashCategoryDropdown.tsx | 121 ++++++++++++------ .../cash-flow/components/CashEditRevenue.tsx | 3 +- .../cash-flow/components/CashSourceList.tsx | 32 ++++- .../cash-flow/components/CategoryFilter.tsx | 109 ++++++++++++++++ .../main-page/grants/filter-bar/FilterBar.tsx | 6 +- .../filter-bar/components/StatusDropdown.tsx | 43 +++---- .../grants/filter-bar/grantFilters.ts | 6 +- 14 files changed, 347 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/CheckboxField.tsx create mode 100644 frontend/src/main-page/cash-flow/components/CategoryFilter.tsx 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/external/bcanSatchel/actions.ts b/frontend/src/external/bcanSatchel/actions.ts index 424d2225..b806cde3 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, @@ -64,7 +66,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 +132,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..f1b8294c 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"; @@ -111,7 +113,7 @@ mutator(logoutUser, () => { // Clears all store filters mutator(clearAllFilters, () => { const store = getAppStore(); - store.filterStatus = null; + store.filterStatus = []; store.startDateFilter = null; store.endDateFilter = null; store.searchQuery = ""; @@ -121,6 +123,8 @@ mutator(clearAllFilters, () => { store.eligibleOnly = false; store.amountMinFilter = null; store.amountMaxFilter = null; + store.filterRevenueCategory = []; + store.filterCostCategory = []; }); /** @@ -149,6 +153,7 @@ mutator(updateFilter, (actionMessage) => { store.filterStatus = actionMessage.status; }); + mutator(updateStartDateFilter, (actionMessage) => { const store = getAppStore(); store.startDateFilter = actionMessage.startDateFilter; @@ -247,3 +252,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 2478cf99..8cb317f0 100644 --- a/frontend/src/external/bcanSatchel/store.ts +++ b/frontend/src/external/bcanSatchel/store.ts @@ -6,14 +6,15 @@ 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 { TDateISO } from '../../../../backend/src/utils/date'; +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; @@ -32,6 +33,8 @@ export interface AppState { revenueSources: CashflowRevenue[]; costSources: CashflowCost[]; cashflowSettings: CashflowSettings | null; + filterRevenueCategory: RevenueType[]; + filterCostCategory: CostType[]; } // Define initial state @@ -40,7 +43,7 @@ const initialState: AppState = { user: null, accessToken: null, allGrants: [], - filterStatus: null, + filterStatus: [], startDateFilter: null, endDateFilter: null, searchQuery: '', @@ -58,6 +61,8 @@ const initialState: AppState = { revenueSources: [], costSources: [], cashflowSettings: null, + filterRevenueCategory: [], + filterCostCategory: [], }; /** diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index e4d91846..1276f00d 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -162,8 +162,7 @@ export default function CashAddEditCost({
{ - const nextType = event.target.value; + onValueChange={(nextType) => { setType(nextType ? (nextType as CostType) : null); }} value={type ?? ""} @@ -192,8 +191,7 @@ export default function CashAddEditCost({
{ - const nextFrequency = event.target.value; + onValueChange={(nextFrequency) => { setFrequency(nextFrequency ? (nextFrequency as Frequency) : null); }} name="Frequency" diff --git a/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx index ec5825dc..434afa75 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx @@ -200,8 +200,7 @@ export default function CashAddRevenue() {
{ - const nextType = event.target.value; + onValueChange={(nextType) => { setType(nextType ? (nextType as RevenueType) : null); }} value={type ?? ""} diff --git a/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx b/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx index 63efe2b0..070ffceb 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/CashEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx index 72eeeefb..168262a6 100644 --- a/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx @@ -236,8 +236,7 @@ export default function CashEditRevenue({
{ - const nextType = event.target.value; + onValueChange={(nextType) => { setType(nextType ? (nextType as RevenueType) : null); }} value={type ?? ""} diff --git a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index be672849..120af907 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react-lite"; import { CashflowCost } from "../../../../../middle-layer/types/CashflowCost"; import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; import { deleteCost, deleteRevenue } from "../processCashflowDataEditSave"; @@ -6,6 +7,10 @@ import CashEditRevenue from "./CashEditRevenue"; import { formatMoney } from "../CashFlowPage"; import { formatDateByFrequency, frequencyLabels } from "../../../../../middle-layer/types/Frequency"; import CashAddEditCost from "./CashAddEditCost"; +import CategoryFilter from "./CategoryFilter"; +import { getAppStore } from "../../../external/bcanSatchel/store"; +import { RevenueType } from "../../../../../middle-layer/types/RevenueType"; +import { CostType } from "../../../../../middle-layer/types/CostType"; type SourceProps = { type: "Revenue" | "Cost"; @@ -23,16 +28,27 @@ const formatInstallmentDate = (dateValue: Date | string) => { return parsedDate.toLocaleDateString(); }; -export default function CashSourceList({ type, lineItems }: SourceProps) { +const CashSourceList = observer(({ type, lineItems }: SourceProps) => { + const { filterRevenueCategory, filterCostCategory } = getAppStore(); + const activeFilter = type === "Revenue" ? filterRevenueCategory : filterCostCategory; + + const filteredItems = activeFilter.length > 0 + ? type === "Revenue" + ? (lineItems as CashflowRevenue[]).filter((item) => (activeFilter as RevenueType[]).includes(item.type)) + : (lineItems as CashflowCost[]).filter((item) => (activeFilter as CostType[]).includes(item.type)) + : lineItems; + return (
-
- {type} - {" Sources"} +
+
+ {type}{" Sources"} +
+
{/* map over list of source and put casheditlineitem for each */} -
- {lineItems.map((item) => ( +
+ {filteredItems.map((item) => (
); -} +}); + +export default CashSourceList; diff --git a/frontend/src/main-page/cash-flow/components/CategoryFilter.tsx b/frontend/src/main-page/cash-flow/components/CategoryFilter.tsx new file mode 100644 index 00000000..6c0c8260 --- /dev/null +++ b/frontend/src/main-page/cash-flow/components/CategoryFilter.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; +import { RevenueType } from "../../../../../middle-layer/types/RevenueType"; +import { CostType } from "../../../../../middle-layer/types/CostType"; +import { getAppStore } from "../../../external/bcanSatchel/store"; +import { + updateRevenueCategoryFilter, + updateCostCategoryFilter, +} from "../../../external/bcanSatchel/actions"; +import Button from "../../../components/Button"; +import CheckboxField from "../../../components/CheckboxField"; + +type CategoryFilterProps = { + type: "Revenue" | "Cost"; +}; + +const CategoryFilter: React.FC = observer(({ type }) => { + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + + const { filterRevenueCategory, filterCostCategory } = getAppStore(); + const selected: RevenueType[] | CostType[] = type === "Revenue" ? filterRevenueCategory : filterCostCategory; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (!dropdownRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, []); + + const handleSelect = (category: RevenueType | CostType) => { + if (type === "Revenue") { + const current = filterRevenueCategory; + const next = current.includes(category as RevenueType) + ? current.filter((c) => c !== category) + : [...current, category as RevenueType]; + updateRevenueCategoryFilter(next); + } else { + const current = filterCostCategory; + const next = current.includes(category as CostType) + ? current.filter((c) => c !== category) + : [...current, category as CostType]; + updateCostCategoryFilter(next); + } + }; + + const activeButtonClass = + "border-2 border-primary-900 text-primary-900 active:!border-primary-900 active:!text-white focus:!border-primary-900 focus:!text-primary-900 focus:outline-none focus-visible:outline-none"; + const inactiveButtonClass = + "border-2 border-grey-500 text-grey-600 active:!border-primary-900 active:!text-white focus:!border-grey-500 focus:!text-grey-600 focus:outline-none focus-visible:outline-none"; + + return ( +
+
+ ); +}); + +export default CategoryFilter; diff --git a/frontend/src/main-page/grants/filter-bar/FilterBar.tsx b/frontend/src/main-page/grants/filter-bar/FilterBar.tsx index a3837fbb..2567964e 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); }; @@ -278,7 +280,7 @@ 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..c4bd24fc 100644 --- a/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx +++ b/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx @@ -5,9 +5,10 @@ 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; } @@ -18,31 +19,23 @@ const StatusDropdown: React.FC = observer(({ selected, onSe
{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 From 2c9e838187c3d589157ab233fb3930f95c4009fb Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 19:38:31 -0400 Subject: [PATCH 12/68] Squashed commit of the following: commit bcd9be146579cf255f04302caab282b7543eec08 Merge: e35be06 10fb2cd Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Mon Apr 13 13:26:17 2026 -0400 Merge branch 'main' into no-editing-grant-revenue commit e35be0619863c056e1890d078e75e6edb2031707 Author: lyannne Date: Sun Apr 12 19:48:11 2026 -0400 resolved copilot comments commit b12d78639d5049cd8d7c1e25e16449e5cefe50fd Merge: 77705e0 2202e50 Author: Lyanne Xu Date: Sun Apr 12 19:36:12 2026 -0400 Merge branch 'main' into no-editing-grant-revenue commit 77705e0bbbfed027d48b9c3113a72ddfdf279239 Author: lyannne Date: Sun Apr 12 19:20:53 2026 -0400 commented out check i think isnt useful with comment for reasoning commit 82e50b4504f556d6e1cedafbfe5bc998f92505a4 Author: lyannne Date: Sun Apr 12 18:26:52 2026 -0400 specified backend type commit 38feb5f555eeb821691c712d21c4e421d4b70770 Author: lyannne Date: Sun Apr 12 18:11:01 2026 -0400 open in grants button commit 46168d2db2760e49e0eef6d6b9bba400fae8dd1d Author: lyannne Date: Sun Apr 12 17:22:45 2026 -0400 grant page grants are displayed and not editable commit db54046d0163e46555501efa1ba9a31669348509 Author: lyannne Date: Sun Apr 12 17:01:08 2026 -0400 backend guard for editing grant page grant as a revenue commit c73cd0627e1eb52a366b3e584af92d950cf11914 Merge: 08a77c8 bb4fe01 Author: lyannne Date: Sun Apr 12 16:52:20 2026 -0400 Merge remote-tracking branch 'origin/392/add-active-grants-to-revenue-list' into no-editing-grant-revenue commit bb4fe01f4d75e56b695b3d2c70494032ff29ca22 Author: Yumiko (Yumi) Chow <75456756+yumi520@users.noreply.github.com> Date: Sun Apr 12 14:19:22 2026 -0400 fixed test to only scan revenue commit 93a0f9c08a314ea7e412f71b4715e9a51d0e1f76 Author: Yumiko (Yumi) Chow <75456756+yumi520@users.noreply.github.com> Date: Sun Apr 12 14:06:00 2026 -0400 moved logic to frontend commit 6b0bafeaa2952dc2922106404d76140af80dd205 Author: Yumiko (Yumi) Chow <75456756+yumi520@users.noreply.github.com> Date: Thu Apr 9 19:52:03 2026 -0400 mapped grants to revenue row --- .../__test__/cashflow-revenue.service.spec.ts | 53 ++++++++- .../src/revenue/cashflow-revenue.service.ts | 12 +- .../cash-flow/components/CashEditLineItem.tsx | 19 +++- .../cash-flow/components/CashSourceList.tsx | 105 ++++++++++-------- .../cash-flow/processCashflowData.ts | 42 ++++++- frontend/src/main-page/grants/GrantPage.tsx | 26 ++++- middle-layer/types/CashflowRevenue.ts | 5 + 7 files changed, 206 insertions(+), 56 deletions(-) diff --git a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts index e50b32b5..177a6fc2 100644 --- a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts +++ b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts @@ -3,6 +3,8 @@ import { BadRequestException, InternalServerErrorException } from '@nestjs/commo 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 ─────────────────────────────────────────────── @@ -43,23 +45,63 @@ const mockDatabase: CashflowRevenue[] = [ { 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', () => { let service: RevenueService; + let revenueItems: CashflowRevenue[]; + let grantItems: Grant[]; beforeAll(() => { 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.DYNAMODB_GRANT_TABLE_NAME = 'test-grant-table'; }); beforeEach(async () => { vi.clearAllMocks(); - mockScan.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.mockReturnValue(resolved({})); mockPut.mockReturnValue(resolved({})); mockDelete.mockReturnValue(resolved({})); @@ -81,6 +123,15 @@ describe('RevenueService', () => { 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 () => { mockScan.mockReturnValue(resolved({ Items: [] })); const result = await service.getAllRevenue(); diff --git a/backend/src/revenue/cashflow-revenue.service.ts b/backend/src/revenue/cashflow-revenue.service.ts index f9b5f0bc..37678f36 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 void) => React.ReactNode; sourceName: string; onRemove: () => Promise; + isReadOnly: boolean; + onReadOnlyAction?: () => void; }; export default function CashEditLineItem({ @@ -15,6 +17,8 @@ export default function CashEditLineItem({ children, sourceName, onRemove, + isReadOnly, + onReadOnlyAction, }: CashEditLineItemProps) { const [editing, setEditing] = useState(false); @@ -41,6 +45,8 @@ export default function CashEditLineItem({
+ {!isReadOnly && ( + <>
)} diff --git a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index 120af907..cf442f50 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -5,9 +5,10 @@ import { deleteCost, deleteRevenue } from "../processCashflowDataEditSave"; import CashEditLineItem from "./CashEditLineItem"; import CashEditRevenue from "./CashEditRevenue"; import { formatMoney } from "../CashFlowPage"; -import { formatDateByFrequency, frequencyLabels } from "../../../../../middle-layer/types/Frequency"; +import { useNavigate } from "react-router-dom"; import CashAddEditCost from "./CashAddEditCost"; import CategoryFilter from "./CategoryFilter"; +import { formatDateByFrequency, frequencyLabels } from "../../../../../middle-layer/types/Frequency"; import { getAppStore } from "../../../external/bcanSatchel/store"; import { RevenueType } from "../../../../../middle-layer/types/RevenueType"; import { CostType } from "../../../../../middle-layer/types/CostType"; @@ -38,6 +39,8 @@ const CashSourceList = observer(({ type, lineItems }: SourceProps) => { : (lineItems as CashflowCost[]).filter((item) => (activeFilter as CostType[]).includes(item.type)) : lineItems; + const navigate = useNavigate(); + return (
@@ -48,13 +51,16 @@ const CashSourceList = observer(({ type, lineItems }: SourceProps) => {
{/* map over list of source and put casheditlineitem for each */}
- {filteredItems.map((item) => ( -
- -
{item.type}
- {type === "Cost" && ( + {filteredItems.map((item) => { + const isGrantPageGrantRevenue = type === "Revenue" && (item as any).isGrantBased === true; + + return ( +
+ +
{item.type}
+ {type === "Cost" && (
{formatMoney(item.amount)}{frequencyLabels.find( (label) => label.value === (item as CashflowCost).frequency, @@ -62,49 +68,58 @@ const CashSourceList = observer(({ type, lineItems }: SourceProps) => { {" "} {formatDateByFrequency((item as CashflowCost).date, (item as CashflowCost).frequency)}
- )} - {type === "Revenue" && ( -
- {(item as CashflowRevenue).installments.map( - (installment, index) => ( + )} + {type === "Revenue" && ( +
+ {(item as CashflowRevenue).installments.map((installment, index) => (
{formatMoney(installment.amount)} {" • "} {formatInstallmentDate(installment.date)}
- ), - )} -
- {"Total: "} - {formatMoney(item.amount)} + ))} +
+ {"Total: "}{formatMoney(item.amount)} +
-
- )} -
- } - sourceName={item.name} - onRemove={() => - type === "Cost" - ? deleteCost(item.name) - : deleteRevenue(item.name) - } - > - {(onClose) => - type === "Cost" ? ( - - ) : ( - - ) - } -
-
- ))} + )} +
+ } + sourceName={item.name} + onRemove={() => + type === "Cost" + ? deleteCost(item.name) + : deleteRevenue(item.name) + } + isReadOnly={isGrantPageGrantRevenue} + onReadOnlyAction={() => { + if (isGrantPageGrantRevenue) { + const grantId = (item as any).grantId; + if (typeof grantId === "number") { + navigate("/main/all-grants", { + state: { selectedGrantId: grantId }, + }); + } + } + }} + > + {(onClose) => + type === "Cost" ? ( + + ) : ( + + ) + } + +
+ ) + })}
); diff --git a/frontend/src/main-page/cash-flow/processCashflowData.ts b/frontend/src/main-page/cash-flow/processCashflowData.ts index 35a6d566..3f8dcb39 100644 --- a/frontend/src/main-page/cash-flow/processCashflowData.ts +++ b/frontend/src/main-page/cash-flow/processCashflowData.ts @@ -1,9 +1,12 @@ 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"; // This has not been tested yet but the basic structure when implemented should be the same @@ -26,12 +29,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}`); + } + + if (!grantResponse.ok) { + throw new Error(`HTTP Error, Status: ${grantResponse.status}`); } - const updatedRevenues: CashflowRevenue[] = await response.json(); - fetchCashflowRevenues(updatedRevenues); + + 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); } diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 3bd452fb..376e6a4f 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) ?? @@ -70,18 +75,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 (
diff --git a/middle-layer/types/CashflowRevenue.ts b/middle-layer/types/CashflowRevenue.ts index c5ff8c1c..8b4ac24d 100644 --- a/middle-layer/types/CashflowRevenue.ts +++ b/middle-layer/types/CashflowRevenue.ts @@ -9,4 +9,9 @@ export interface CashflowRevenue {     name : string;     installments: Installment[]; +} + +export interface GrantPageGrant extends CashflowRevenue { +isGrantBased: true; // Required to be true +grantId: number; // Required when isGrantBased is true } \ No newline at end of file From 860ce6ca2cd65ebde80795f7a270d57295bdeb7a Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 19:51:36 -0400 Subject: [PATCH 13/68] Squashed commit of the following: commit b494ab96137c72176ea50d0d351ff40e08317c0b Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Tue Apr 14 19:50:34 2026 -0400 Styling fixes (#425) commit bd405053a2c0f4efbd456e156a46d9e8c190cf68 Merge: 928ad1f e08b3fd Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Tue Apr 14 18:46:04 2026 -0400 Merge pull request #414 from Code-4-Community/backgroundFix background fix commit e08b3fd7788ec5d67b1e4711d298aca6493b5363 Merge: 6ca3482 10fb2cd Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Mon Apr 13 13:24:48 2026 -0400 Merge branch 'main' into backgroundFix commit 6ca3482dfaf4058976376ba63853f2440bb7c21f Author: adityapat24 Date: Sun Apr 12 17:22:07 2026 -0400 removed media query commit 36c4cb3d7ae9b5f524a16f5500925b0381b80d02 Author: adityapat24 Date: Sun Apr 12 13:59:18 2026 -0400 background fix --- frontend/src/components/ActionConfirmation.tsx | 2 +- frontend/src/components/Button.tsx | 2 +- .../main-page/cash-flow/components/CashAddRevenue.tsx | 2 +- .../cash-flow/components/CashEditLineItem.tsx | 2 +- .../cash-flow/components/CashRevenueInstallment.tsx | 2 +- frontend/src/main-page/grants/GrantPage.tsx | 9 ++++++++- .../src/main-page/grants/edit-grant/EditGrant.tsx | 2 +- .../grants/edit-grant/components/EditGrantInfo.tsx | 2 +- .../src/main-page/grants/grant-view/GrantView.tsx | 2 +- .../src/main-page/settings/ChangePasswordModal.tsx | 11 ++--------- .../src/main-page/users/components/UserApprove.tsx | 2 +- frontend/src/main-page/users/components/UserMenu.tsx | 2 +- frontend/src/main-page/users/components/UserRow.tsx | 2 +- .../src/main-page/users/components/UserRowHeader.tsx | 4 ++-- 14 files changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/ActionConfirmation.tsx b/frontend/src/components/ActionConfirmation.tsx index 872841c9..e037bc36 100644 --- a/frontend/src/components/ActionConfirmation.tsx +++ b/frontend/src/components/ActionConfirmation.tsx @@ -68,7 +68,7 @@ import { IoIosWarning } from "react-icons/io";
diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 5d073dd6..1817493b 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -51,7 +51,7 @@ export default function Button({ )} {logo && logoPosition === "center" && ( - + )} diff --git a/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx index 434afa75..d199543f 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx @@ -298,7 +298,7 @@ export default function CashAddRevenue() { text="Add Revenue Source" onClick={handleSubmit} disabled={isSubmitting} - className="bg-green hover:!border-green text-white mt-2 text-sm lg:text-base" + className="bg-green hover:!border-green text-white mt-2 text-sm lg:text-base active:!bg-green active:!border-green w-full" />
); diff --git a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx index b0fe2068..faf78dac 100644 --- a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx +++ b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx @@ -59,7 +59,7 @@ export default function CashEditLineItem({ logo={faTrash} logoPosition="right" onClick={() => setShowDeleteModal(true)} - className="bg-red-light text-red hover:!border-red text-sm lg:text-base" + className="bg-red-light text-red hover:!border-red text-sm lg:text-base active:!bg-red active:!border-red" /> )} diff --git a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx index 5a4ba790..440bb0de 100644 --- a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx +++ b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx @@ -77,7 +77,7 @@ export default function CashRevenueInstallment({ onClick={() => onDelete?.()} logo={faTrash} logoPosition="center" - className="bg-red-light text-red w-10 h-10 p-0 rounded-full mb-1.5 shrink-0 border-0" + className="bg-red-light text-red w-10 h-10 p-0 rounded-full mb-1.5 shrink-0 border-0 active:!bg-red active:!border-red hover:!border-red" /> ) : (
diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 376e6a4f..02c825ba 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -38,6 +38,13 @@ function GrantPage() { grants[0] ?? null; + const mainContainer = document.getElementsByClassName('grant-container'); + + useEffect(() => { + clearAllFilters(); + mainContainer[0].scrollTo(0, 0); + }, [curGrant]); + const handleGrantCreated = (grantId: number) => { setCurId(grantId); @@ -132,7 +139,7 @@ function GrantPage() { /> ))}
-
+
) : (
diff --git a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx index 6e400a08..0fa5f428 100644 --- a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx +++ b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx @@ -211,7 +211,7 @@ const EditGrant: React.FC<{ {/* Divider */} {grantToEdit && (

- +
diff --git a/frontend/src/main-page/users/components/UserApprove.tsx b/frontend/src/main-page/users/components/UserApprove.tsx index 4cfc7d63..565cb954 100644 --- a/frontend/src/main-page/users/components/UserApprove.tsx +++ b/frontend/src/main-page/users/components/UserApprove.tsx @@ -11,7 +11,7 @@ const UserApprove = ({ user }: UserApproveProps) => { const [isLoading, setIsLoading] = useState(false); return ( -
+
+ )}
); } 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/CashEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx deleted file mode 100644 index 168262a6..00000000 --- a/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx +++ /dev/null @@ -1,342 +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 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 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 handleSave = async () => { - const payload = buildPayload(); - if (!payload) { - return; - } - - 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 ( -
-
-
- setName(event.target.value)} - /> - {errors.name ?

{errors.name}

: null} -
-
- { - 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/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index cf442f50..cda1098a 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -3,7 +3,7 @@ import { CashflowCost } from "../../../../../middle-layer/types/CashflowCost"; import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue"; import { deleteCost, deleteRevenue } from "../processCashflowDataEditSave"; import CashEditLineItem from "./CashEditLineItem"; -import CashEditRevenue from "./CashEditRevenue"; +import CashAddEditRevenue from "./CashAddEditRevenue"; import { formatMoney } from "../CashFlowPage"; import { useNavigate } from "react-router-dom"; import CashAddEditCost from "./CashAddEditCost"; @@ -110,7 +110,7 @@ const CashSourceList = observer(({ type, lineItems }: SourceProps) => { onClose={onClose} /> ) : ( - From 99f709fc0f0213675174e7856807bcc385da1080 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 20:26:49 -0400 Subject: [PATCH 15/68] When you click on item it goes to grant --- .../components/GanttYearGrantTimeline.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) 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; From c54dc73507aa959047af04ad9f42a9e181753fde Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 22:01:56 -0400 Subject: [PATCH 16/68] Date filter and formatting --- .../src/main-page/cash-flow/components/CashProjection.tsx | 4 ++-- frontend/src/main-page/cash-flow/projection.ts | 4 ++-- frontend/src/main-page/grants/GrantPage.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashProjection.tsx b/frontend/src/main-page/cash-flow/components/CashProjection.tsx index e5f38640..672610a4 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjection.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjection.tsx @@ -33,9 +33,9 @@ export default function CashProjection({ data, kpis }: ProjectionProps) { {cards.map((c) => (
-
{c.field}
+
{c.field}
{c.value.toLocaleString("en-US", { style: "currency", diff --git a/frontend/src/main-page/cash-flow/projection.ts b/frontend/src/main-page/cash-flow/projection.ts index 7a367186..f432e416 100644 --- a/frontend/src/main-page/cash-flow/projection.ts +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -111,7 +111,7 @@ export function buildCashflowProjection( for (const rev of revenues) { for (const installment of rev.installments) { const key = toMonthKey(installment.date); - if (revenueBuckets.has(key)) { + if (revenueBuckets.has(key) && installment.date >= new Date(settings.startDate)) { revenueBuckets.set(key, revenueBuckets.get(key)! + installment.amount); } } @@ -122,7 +122,7 @@ export function buildCashflowProjection( if (cost.frequency === Frequency.OneTime) { // Single occurrence const key = toMonthKey(new Date(cost.date)); - if (costBuckets.has(key)) { + 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); } diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 02c825ba..4a117768 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -42,7 +42,7 @@ function GrantPage() { useEffect(() => { clearAllFilters(); - mainContainer[0].scrollTo(0, 0); + curGrant && mainContainer[0].scrollTo(0, 0); }, [curGrant]); const handleGrantCreated = (grantId: number) => { From 48560576de8c100ef0c6e8830f2dbfee9b2bd7be Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 22:54:07 -0400 Subject: [PATCH 17/68] Default date, resolving frontend errors from console --- .../src/main-page/cash-flow/CashFlowPage.tsx | 22 +++++++++---------- .../cash-flow/components/CashPosition.tsx | 1 - .../cash-flow/components/CashSourceList.tsx | 4 ++-- .../src/main-page/cash-flow/projection.ts | 6 ++--- frontend/src/main-page/navbar/NavBar.tsx | 2 +- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index eda0a1b7..56212471 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -26,16 +26,14 @@ export const formatMoney = (amount: number) => { const CashFlowPage = observer(() => { const { costs, revenues, cashflowSettings } = ProcessCashflowData(); - const { chartData, kpis } = buildCashflowProjection( - revenues, - costs, - cashflowSettings || { - startingCash: 0, - salaryIncrease: 0, - benefitsIncrease: 0, - startDate: new Date().toISOString().split("T")[0] as TDateISO, - }, - ); + const { chartData, kpis } = buildCashflowProjection(revenues, costs, { + startingCash: cashflowSettings?.startingCash ?? 0, + salaryIncrease: cashflowSettings?.salaryIncrease ?? 0, + benefitsIncrease: cashflowSettings?.benefitsIncrease ?? 0, + startDate: + cashflowSettings?.startDate ?? + (new Date().toISOString().split("T")[0] as TDateISO), + }); return (
@@ -86,7 +84,9 @@ const CashFlowPage = observer(() => { {/* Row 4 */} - {revenues.length > 0 && } + {revenues.length > 0 && ( + + )} {costs.length > 0 && }
diff --git a/frontend/src/main-page/cash-flow/components/CashPosition.tsx b/frontend/src/main-page/cash-flow/components/CashPosition.tsx index 09b1f1c0..f5b73910 100644 --- a/frontend/src/main-page/cash-flow/components/CashPosition.tsx +++ b/frontend/src/main-page/cash-flow/components/CashPosition.tsx @@ -39,7 +39,6 @@ const CashPosition = observer(() => { {
{/* map over list of source and put casheditlineitem for each */}
- {filteredItems.map((item) => { + {filteredItems.map((item, index) => { const isGrantPageGrantRevenue = type === "Revenue" && (item as any).isGrantBased === true; return ( -
+
diff --git a/frontend/src/main-page/cash-flow/projection.ts b/frontend/src/main-page/cash-flow/projection.ts index f432e416..e64859e9 100644 --- a/frontend/src/main-page/cash-flow/projection.ts +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -135,13 +135,13 @@ export function buildCashflowProjection( const [cy, cm] = cost.date.split("-").map(Number); const cursor = new Date(cy, cm - 1, 1); - // If the cost starts before the projection, advance to the first - // occurrence that falls within the window. - while (toMonthKey(cursor) < startKey) { + // Advance past occurrences that fall before the exact start date + while (cursor < new Date(settings.startDate)) { cursor.setMonth(cursor.getMonth() + interval); } while (toMonthKey(cursor) <= endKey) { + console.log(`Cost "${cost.name}" occurs on ${cost.date} with amount ${cost.amount}`); const key = toMonthKey(cursor); if (costBuckets.has(key)) { const adjusted = getAdjustedCostAmount(cost, key, settings); diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx index b7dab5a9..553d850a 100644 --- a/frontend/src/main-page/navbar/NavBar.tsx +++ b/frontend/src/main-page/navbar/NavBar.tsx @@ -29,7 +29,7 @@ const NavBar: React.FC = observer(() => { const handleLogout = async () => { const { cashflowSettings } = getAppStore(); if (cashflowSettings) { - await saveCashflowSettings(cashflowSettings); + saveCashflowSettings(cashflowSettings); } logoutUser(); clearAllFilters(); From 06f5d0946cfa27ce8ba7475f67a8d9b7b672b484 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 23:53:08 -0400 Subject: [PATCH 18/68] Updating kpis and projection date filters --- .../src/main-page/cash-flow/CashFlowPage.tsx | 18 ++++-------------- .../cash-flow/components/CashAddEditCost.tsx | 9 +++++---- .../components/CashProjectionChart.tsx | 5 ++--- frontend/src/main-page/cash-flow/projection.ts | 8 ++++---- 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index 56212471..a8a50a12 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -46,24 +46,14 @@ const CashFlowPage = observer(() => { className="text-blue" /> accumulator + currentValue.amount, - 0, - ), - )} + text="Projected Total Revenue" + value={formatMoney(kpis.totalRevenue ?? 0)} logo={faArrowTrendUp} className="text-green" /> accumulator + currentValue.amount, - 0, - ) / 12, - )} + text="Projected Total Costs" + value={formatMoney(kpis.totalCosts ?? 0)} logo={faUserGroup} className="text-primary" /> diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index 1276f00d..a6e07018 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -28,6 +28,9 @@ export default function CashAddEditCost({ costItem, onClose = () => {}, }: CashEditCostProps) { + + const { cashflowSettings } = getAppStore(); + const [type, setType] = useState( costItem ? costItem.type : null, ); @@ -44,14 +47,12 @@ export default function CashAddEditCost({ costItem ? costItem.interval : null, ); const [date, setDate] = useState( - costItem ? costItem.date : null, + costItem ? costItem.date : cashflowSettings?.startDate ?? null, ); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [successMessage, setSuccessMessage] = useState(null); - const { cashflowSettings } = getAppStore(); - const showSuccessMessage = (message: string) => { setSuccessMessage(message); setTimeout(() => { @@ -207,7 +208,7 @@ export default function CashAddEditCost({ type="date" id="date" label="Start Date" - value={date ?? cashflowSettings?.startDate} + value={date ?? ""} onChange={(event) => setDate( event.target.value ? (event.target.value as TDateISO) : null, diff --git a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx index 87206ecf..96cbc712 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx @@ -39,9 +39,8 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { stroke="var(--color-blue)" strokeWidth={1.5} dot={{ r: 2.5 }} - name="Cash Balance" + name="End Balance" /> - { textAlign: "left", }} labelFormatter={formatMonthYear} - formatter={(value: number) => `$${value.toLocaleString()}`} + formatter={(value: number) => `$${value.toLocaleString("en-US", { minimumFractionDigits: 2 })}`} /> diff --git a/frontend/src/main-page/cash-flow/projection.ts b/frontend/src/main-page/cash-flow/projection.ts index e64859e9..dd309a68 100644 --- a/frontend/src/main-page/cash-flow/projection.ts +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -96,7 +96,6 @@ export function buildCashflowProjection( settings: CashflowSettings, ): CashflowProjectionResult { const monthKeys = generateMonthKeys(settings.startDate); - const startKey = monthKeys[0]; const endKey = monthKeys[monthKeys.length - 1]; // Pre-allocate monthly buckets @@ -110,8 +109,10 @@ export function buildCashflowProjection( // ---- Distribute revenues into monthly buckets ---- for (const rev of revenues) { for (const installment of rev.installments) { + console.log(`Processing revenue installment of $${installment.amount} on ${installment.date}`); const key = toMonthKey(installment.date); - if (revenueBuckets.has(key) && installment.date >= new Date(settings.startDate)) { + if (revenueBuckets.has(key) && new Date(installment.date) >= new Date(settings.startDate)) { + console.log(`Adding revenue installment of $${installment.amount} to month ${key}`); revenueBuckets.set(key, revenueBuckets.get(key)! + installment.amount); } } @@ -136,12 +137,11 @@ export function buildCashflowProjection( const cursor = new Date(cy, cm - 1, 1); // Advance past occurrences that fall before the exact start date - while (cursor < new Date(settings.startDate)) { + 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) { - console.log(`Cost "${cost.name}" occurs on ${cost.date} with amount ${cost.amount}`); const key = toMonthKey(cursor); if (costBuckets.has(key)) { const adjusted = getAdjustedCostAmount(cost, key, settings); From f386445b8e42115019da03390be94e6b056210c5 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Tue, 14 Apr 2026 23:54:24 -0400 Subject: [PATCH 19/68] Saving cashflow settings on page change --- frontend/src/main-page/MainPage.tsx | 5 +++++ frontend/src/main-page/navbar/NavBar.tsx | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/main-page/MainPage.tsx b/frontend/src/main-page/MainPage.tsx index 443003ca..7f6b7147 100644 --- a/frontend/src/main-page/MainPage.tsx +++ b/frontend/src/main-page/MainPage.tsx @@ -15,6 +15,7 @@ import { getAppStore } from "../external/bcanSatchel/store"; import BellButton from "./notifications/Bell"; import { useEffect, useState } from "react"; import { clearAllFilters } from "../external/bcanSatchel/actions"; +import { saveCashflowSettings } from "./cash-flow/processCashflowDataEditSave"; interface PositionGuardProps { children: React.ReactNode; @@ -55,6 +56,10 @@ function MainPage() { useEffect(() => { clearAllFilters(); mainContainer[0].scrollTo(0, 0); + const { cashflowSettings } = getAppStore(); + if (cashflowSettings) { + saveCashflowSettings(cashflowSettings); + } }, [location]); return ( diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx index 553d850a..a7370102 100644 --- a/frontend/src/main-page/navbar/NavBar.tsx +++ b/frontend/src/main-page/navbar/NavBar.tsx @@ -11,7 +11,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"; const tabs: NavTabProps[] = [ { name: "Dashboard", linkTo: "/main/dashboard", icon: faChartLine }, @@ -27,10 +26,6 @@ const NavBar: React.FC = observer(() => { const isAdmin = user?.position === UserStatus.Admin; const handleLogout = async () => { - const { cashflowSettings } = getAppStore(); - if (cashflowSettings) { - saveCashflowSettings(cashflowSettings); - } logoutUser(); clearAllFilters(); navigate("/login"); From 3c930939ad9d73582df254d8c7970dd881d71df3 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 00:06:11 -0400 Subject: [PATCH 20/68] Updating tests --- backend/src/cost/__test__/cashflow-cost.service.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/cost/__test__/cashflow-cost.service.spec.ts b/backend/src/cost/__test__/cashflow-cost.service.spec.ts index efdffb98..a8c8cd62 100644 --- a/backend/src/cost/__test__/cashflow-cost.service.spec.ts +++ b/backend/src/cost/__test__/cashflow-cost.service.spec.ts @@ -362,7 +362,7 @@ describe('CostService', () => { type: CostType.Services, frequency: Frequency.OneTime, date: '2026-03-22' as TDateISO, - interval: 12, + interval: 0, }; mockPutPromise.mockResolvedValue({}); @@ -578,6 +578,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }); expect(mockGet).toHaveBeenCalledWith({ @@ -636,6 +637,7 @@ describe('CostService', () => { amount: 300, type: CostType.MealsFood, frequency: Frequency.Yearly, + interval: 12, date: '2026-03-22', }); }); From 14aa287eb1378fb1760a8bc8c0d51299b49acb1d Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 00:10:33 -0400 Subject: [PATCH 21/68] Fixing test --- backend/src/cost/__test__/cashflow-cost.service.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/cost/__test__/cashflow-cost.service.spec.ts b/backend/src/cost/__test__/cashflow-cost.service.spec.ts index a8c8cd62..4a043953 100644 --- a/backend/src/cost/__test__/cashflow-cost.service.spec.ts +++ b/backend/src/cost/__test__/cashflow-cost.service.spec.ts @@ -596,6 +596,7 @@ describe('CostService', () => { type: CostType.MealsFood, frequency: Frequency.Yearly, date: '2026-03-22', + interval: 12, }, ConditionExpression: 'attribute_not_exists(#name)', ExpressionAttributeNames: { From 17cd6b055041d1321f409a4ebf231756f35b3c77 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 14:27:05 -0400 Subject: [PATCH 22/68] Removing use effect --- frontend/src/main-page/grants/GrantPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 4a117768..13b46d22 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -41,7 +41,6 @@ function GrantPage() { const mainContainer = document.getElementsByClassName('grant-container'); useEffect(() => { - clearAllFilters(); curGrant && mainContainer[0].scrollTo(0, 0); }, [curGrant]); From 68cae258fbb7a713703e61f2c8e6a14b3ba285fb Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 14:48:34 -0400 Subject: [PATCH 23/68] Fixing empty settings --- frontend/src/main-page/cash-flow/CashFlowPage.tsx | 2 +- .../src/main-page/cash-flow/processCashflowDataEditSave.ts | 3 +++ frontend/src/main-page/cash-flow/projection.ts | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index a8a50a12..60294dd8 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -59,7 +59,7 @@ const CashFlowPage = observer(() => { /> { ]; for (const update of updates) { + if (update.value === undefined || update.value === null) { + 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 index dd309a68..8009ad1a 100644 --- a/frontend/src/main-page/cash-flow/projection.ts +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -200,10 +200,10 @@ function getAdjustedCostAmount( const costStartKey = toMonthKey(cost.date); const years = yearsElapsed(costStartKey, currentMonthKey); - if (cost.type === CostType.Salary) { + 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) { + if (cost.type === CostType.Benefits && Number.isNaN(settings.benefitsIncrease) === false) { return cost.amount * Math.pow(1 + (settings.benefitsIncrease/100), years); } return cost.amount; From 75a97b9355eb7c2987e264755d8e1f949feb9b00 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 14:53:08 -0400 Subject: [PATCH 24/68] Fixing nan settings save --- frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts b/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts index 0a3e50c5..854c3e3f 100644 --- a/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts +++ b/frontend/src/main-page/cash-flow/processCashflowDataEditSave.ts @@ -220,7 +220,7 @@ export const saveCashflowSettings = async (settings: CashflowSettings) => { ]; for (const update of updates) { - if (update.value === undefined || update.value === null) { + 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", { From a1e0bb153167e96c1722bf0508b151f5e0fe51c6 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 15:46:07 -0400 Subject: [PATCH 25/68] Formatting --- .../cash-flow/components/CashAddEditRevenue.tsx | 6 ++++-- .../components/CashRevenueInstallment.tsx | 16 ++++++++-------- .../cash-flow/components/CashSourceList.tsx | 4 ++-- .../grant-view/components/StatusIndicator.tsx | 2 +- middle-layer/types/Frequency.ts | 12 ++++++++++-- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx index 71198813..1c456b75 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx @@ -377,11 +377,12 @@ export default function CashAddEditRevenue({ className="bg-green hover:!border-green text-white mt-2 text-sm lg:text-base active:!bg-green active:!border-green w-full" /> ) : ( -
-
+
+
{"Total: "} {formatMoney(totalAmount)}
+
)}
diff --git a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx index 440bb0de..ecae74ac 100644 --- a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx +++ b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx @@ -1,6 +1,7 @@ import InputField from "../../../components/InputField"; -import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faXmark } 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 +73,12 @@ export default function CashRevenueInstallment({ />
{showDelete ? ( - ); }; diff --git a/middle-layer/types/Frequency.ts b/middle-layer/types/Frequency.ts index a16369a6..5720143d 100644 --- a/middle-layer/types/Frequency.ts +++ b/middle-layer/types/Frequency.ts @@ -11,7 +11,7 @@ export const frequencyLabels = [ { value: Frequency.Yearly, label: "/year" }, { value: Frequency.Monthly, label: "/month" }, { value: Frequency.OneTime, label: "" }, - { value: Frequency.Custom, label: " recurring" } + { value: Frequency.Custom, label: "" } ]; export const frequencyIntervalsInMonths: Record = { @@ -23,7 +23,8 @@ export const frequencyIntervalsInMonths: Record = { export function formatDateByFrequency( date: string | Date, - frequency: Frequency + frequency: Frequency, + interval: number ): string { const parsedDate = typeof date === "string" ? new Date(date + "T00:00:00") : new Date(date + "T00:00:00"); @@ -38,6 +39,13 @@ export function formatDateByFrequency( year: "2-digit", }).format(parsedDate); + case Frequency.Custom: + return `every ${interval} month${interval > 1 ? "s" : ""} starting ` + new Intl.DateTimeFormat("en-US", { + month: "2-digit", + day: "2-digit", + year: "2-digit", + }).format(parsedDate); + default: return "starting " + new Intl.DateTimeFormat("en-US", { month: "2-digit", From 76644bae03ad50caffa4b03c95079a088fe34e51 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 16:01:16 -0400 Subject: [PATCH 26/68] Styling --- .../src/components/ActionConfirmation.tsx | 2 +- .../dashboard/components/DateFilter.tsx | 51 +++++++++++-------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/ActionConfirmation.tsx b/frontend/src/components/ActionConfirmation.tsx index e037bc36..ad827f85 100644 --- a/frontend/src/components/ActionConfirmation.tsx +++ b/frontend/src/components/ActionConfirmation.tsx @@ -64,7 +64,7 @@ import { IoIosWarning } from "react-icons/io"; {/* Buttons */}
-
    {uniqueYears.map((year) => ( -
  • -
    - handleCheckboxChange({ target: { value: year.toString(), checked: !selectedYears.includes(year) } } as React.ChangeEvent)} + label={
    {year.toString()}
    } /> - -
    -
  • ))}

From 24e541e2eac3e7bf9ca3905a03452028fdab5969 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 18:17:29 -0400 Subject: [PATCH 27/68] Removing unused imports --- .../main-page/cash-flow/components/CashRevenueInstallment.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx b/frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx index ecae74ac..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,4 @@ import InputField from "../../../components/InputField"; -import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import Button from "../../../components/Button"; import { FaXmark } from "react-icons/fa6"; export type EditableInstallment = { From 8317846be9b345ea69ce5d6d3f5d6025186596b3 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 19:00:26 -0400 Subject: [PATCH 28/68] Fixing duplicate revenue name on edit allowed --- backend/src/revenue/cashflow-revenue.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/src/revenue/cashflow-revenue.service.ts b/backend/src/revenue/cashflow-revenue.service.ts index 37678f36..914b4b5e 100644 --- a/backend/src/revenue/cashflow-revenue.service.ts +++ b/backend/src/revenue/cashflow-revenue.service.ts @@ -374,6 +374,19 @@ async updateRevenue(name: string, revenue: CashflowRevenue): Promise Date: Wed, 15 Apr 2026 19:08:18 -0400 Subject: [PATCH 29/68] clear all to multi-select status, added lg breakpoint --- .../src/main-page/grants/filter-bar/FilterBar.tsx | 10 +++++++++- .../grants/filter-bar/components/StatusDropdown.tsx | 12 +++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/main-page/grants/filter-bar/FilterBar.tsx b/frontend/src/main-page/grants/filter-bar/FilterBar.tsx index 2567964e..d6a30c15 100644 --- a/frontend/src/main-page/grants/filter-bar/FilterBar.tsx +++ b/frontend/src/main-page/grants/filter-bar/FilterBar.tsx @@ -156,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; @@ -284,7 +288,11 @@ const FilterBar: React.FC = observer(() => { }`} /> {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 c4bd24fc..89e421c2 100644 --- a/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx +++ b/frontend/src/main-page/grants/filter-bar/components/StatusDropdown.tsx @@ -10,14 +10,15 @@ import CheckboxField from "../../../../components/CheckboxField"; interface StatusDropdownProps { 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) => ( = observer(({ selected, onSe } /> ))} +
); From 2ea53c1d6461df2a9929ffef14c52a90e68403a3 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 19:21:57 -0400 Subject: [PATCH 30/68] Saving button styling --- .../src/main-page/cash-flow/components/CashAddEditCost.tsx | 3 ++- frontend/src/main-page/grants/edit-grant/EditGrant.tsx | 2 +- frontend/src/main-page/settings/Settings.tsx | 7 ++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index a6e07018..9b1bd263 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -282,8 +282,9 @@ export default function CashAddEditCost({ className="bg-white text-black border border-grey-500 mt-2 text-sm lg:text-base" />
diff --git a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx index 0fa5f428..f910b619 100644 --- a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx +++ b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx @@ -185,7 +185,7 @@ const EditGrant: React.FC<{ onClick={onClose} />
From c5d60c40dd8cdd843260f8a43987ab0e96fe3e20 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 19:28:13 -0400 Subject: [PATCH 31/68] Removing commented out code --- frontend/src/main-page/users/UsersPage.tsx | 58 +--------------------- 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/frontend/src/main-page/users/UsersPage.tsx b/frontend/src/main-page/users/UsersPage.tsx index e1bd08d0..3a5b6f0b 100644 --- a/frontend/src/main-page/users/UsersPage.tsx +++ b/frontend/src/main-page/users/UsersPage.tsx @@ -14,21 +14,10 @@ const UsersPage = observer(() => { const [showAll, setShowAll] = useState(true); const { activeUsers, inactiveUsers } = ProcessUserData(); - //const ITEMS_PER_PAGE = 8; - - //const [currentPage, setCurrentPage] = useState(1); const filteredUsers = showAll ? activeUsers : inactiveUsers; - // const numUsers = filteredUsers.length; - // const pageStartIndex = (currentPage - 1) * ITEMS_PER_PAGE; - // const pageStartIndex = 1; // Temporarily disable pagination by always starting at index 0 - // const pageEndIndex = - // pageStartIndex + ITEMS_PER_PAGE > numUsers - // ? numUsers - // : pageStartIndex + ITEMS_PER_PAGE; - // const currentPageUsers = filteredUsers.slice(pageStartIndex, pageEndIndex); - const currentPageUsers = filteredUsers; // Temporarily disable pagination by showing all users + const currentPageUsers = filteredUsers // Temporarily disable pagination by showing all users return (
@@ -74,51 +63,6 @@ const UsersPage = observer(() => { )}
- {/* Commenting out pagination for now to check if needed */} - {/* { - setCurrentPage(e.page); - }} - > - - - - - - - - {({ pages }) => - pages.map((page, index) => - page.type === "page" ? ( - setCurrentPage(page.value)} - aria-label={`Go to page ${page.value}`} - > - {page.value} - - ) : ( - "..." - ), - ) - } - - - - - - - - */}
); }); From 461e6ee794052a01806d09f426e5e6472d37e6db Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 20:45:40 -0400 Subject: [PATCH 32/68] Removing alerts and logging instead --- frontend/src/main-page/users/UserActions.ts | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/main-page/users/UserActions.ts b/frontend/src/main-page/users/UserActions.ts index ddffc50d..380f9536 100644 --- a/frontend/src/main-page/users/UserActions.ts +++ b/frontend/src/main-page/users/UserActions.ts @@ -85,15 +85,17 @@ export const approveUser = async ( }), }); if (response.ok) { - alert(`User ${user.email} has been approved successfully`); const body = await response.json(); moveUserToActive(body as User); + console.log(`User ${user.email} has been approved successfully`); + return { success: true, message: `User ${user.email} has been approved successfully` }; } else { - alert("Failed to approve user"); - } + const errorBody = await response.json(); + console.error("Error: ", errorBody); + return { success: false, message: errorBody.message || "Failed to delete user" }; } } catch (error) { console.error("Error approving user:", error); - alert("Error approving user"); + return { success: false, message: "Error deleting user" }; } finally { setIsLoading(false); } @@ -122,17 +124,17 @@ export const deleteUser = async ( if (response.ok) { console.log(`User ${user.email} has been deleted successfully`); - alert(`User ${user.email} has been deleted successfully`); const body = await response.json(); removeUser(body); + return { success: true, message: `User ${user.email} has been deleted successfully` }; } else { const errorBody = await response.json(); console.error("Error: ", errorBody); - alert("Failed to delete user"); + return { success: false, message: errorBody.message || "Failed to delete user" }; } } catch (error) { console.error("Error deleting user:", error); - alert("Error deleting user"); + return { success: false, message: "Error deleting user" }; } finally { setIsLoading(false); } @@ -168,21 +170,19 @@ export const changeUserGroup = async (user: User) => { user.position === UserStatus.Admin ? "employee" : "admin" }`, ); - alert( - `User ${user.email} successfully changed to ${ - user.position === UserStatus.Admin ? "employee" : "admin" - }`, - ); const updatedUser = await response.json(); setActiveUsers([ ...store.activeUsers.filter((u) => u.email !== user.email), updatedUser as User, ]); + return { success: true, message: `User ${user.email} has been changed to ${user.position === UserStatus.Admin ? "employee" : "admin"} successfully` }; } else { const errorBody = await response.json(); console.error("Error: ", errorBody); + return { success: false, message: errorBody.message || `Failed to change user group` }; } } catch (error) { console.error("Error changing user group: ", error); + return { success: false, message: "Error changing user group" }; } }; From 0fbd4c3789a04589e6291d0617662016b13f40b6 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 20:46:18 -0400 Subject: [PATCH 33/68] Squashed commit of the following: commit 5e6603621d7531a6bbfd3b4be9f99a13b23b8b8d Merge: 8dec21b fefdad9 Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Wed Apr 15 18:09:59 2026 -0400 Merge pull request #429 from Code-4-Community/bug-fix-filters Fixing filters bug commit fefdad942d5048c1110615a3e2cbb8d6e0c6962a Author: Jane Kamata Date: Wed Apr 15 14:15:50 2026 -0400 Fixing filters bug commit 8dec21be93ed37bc8c712331417302dc77ec3c30 Merge: 6490d98 5d56537 Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Wed Apr 15 12:53:24 2026 -0400 Merge pull request #391 from Code-4-Community/382-dev---update-delete-grant-so-it-notifications-as-well update delete grant so it deletes notifs too commit 5d565375ca093021abc621386f8af2fe6885eb43 Author: Camila Carrillo Date: Fri Apr 3 20:17:31 2026 -0400 updated tests commit ba316f264245ca6792704796a0024acab64c8044 Author: Camila Carrillo Date: Fri Apr 3 19:29:31 2026 -0400 update delete grant so it deletes notifs too --- .../src/grant/__test__/grant.service.spec.ts | 56 +++++++++++++++++-- backend/src/grant/grant.service.ts | 48 ++++++++++------ 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index c90990aa..3252bdda 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -171,14 +171,15 @@ describe("GrantService", () => { grantService = Object.assign(module.get(GrantService), { notificationService: { createNotification: vi.fn(), - updateNotification: vi.fn() + updateNotification: vi.fn(), + getNotificationByUserEmail: vi.fn().mockResolvedValue([]), + deleteNotification: vi.fn().mockResolvedValue('deleted') } }); controller = module.get(GrantController); - grantService = module.get(GrantService); }); @@ -585,10 +586,18 @@ describe("GrantService", () => { // Tests for deleteGrantById method describe('deleteGrantById', () => { it('should call DynamoDB delete with the correct params and return success message', async () => { + mockGet.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Item: mockGrants[0] }) + }); + mockDelete.mockReturnValue({ promise: vi.fn().mockResolvedValue({}) }); + mockQuery.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Items: [] }) + }); + const result = await grantService.deleteGrantById(123); expect(mockDelete).toHaveBeenCalledTimes(1); //ensures delete() was called once @@ -610,6 +619,14 @@ describe('deleteGrantById', () => { const conditionalError = new Error('Conditional check failed'); (conditionalError as any).code = 'ConditionalCheckFailedException'; + mockGet.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Item: mockGrants[0] }) + }); + + mockQuery.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Items: [] }) + }); + mockDelete.mockReturnValue({ promise: vi.fn().mockRejectedValue(conditionalError) }); @@ -629,6 +646,14 @@ describe('deleteGrantById', () => { const conditionalError = new Error('Conditional check failed'); (conditionalError as any).code = 'ConditionalCheckFailedException'; + mockGet.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Item: mockGrants[0] }) + }); + + mockQuery.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Items: [] }) + }); + mockDelete.mockReturnValue({ promise: vi.fn().mockRejectedValue(conditionalError) }); @@ -643,6 +668,14 @@ describe('deleteGrantById', () => { const awsError = new Error('AWS DynamoDB error'); (awsError as any).code = 'ThrottlingException'; + mockGet.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Item: mockGrants[0] }) + }); + + mockQuery.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Items: [] }) + }); + mockDelete.mockReturnValue({ promise: vi.fn().mockRejectedValue(awsError) }); @@ -654,6 +687,15 @@ describe('deleteGrantById', () => { }); it('should throw InternalServerErrorException for generic DynamoDB errors', async () => { + + mockGet.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Item: mockGrants[0] }) + }); + + mockQuery.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Items: [] }) + }); + mockDelete.mockReturnValue({ promise: vi.fn().mockRejectedValue(new Error('Some other DynamoDB error')) }); @@ -684,11 +726,15 @@ describe('Notification helpers', () => { const result = (grantServiceWithMockNotif as any).getNotificationTimes(deadline); expect(result).toHaveLength(3); - result.forEach((date: any) => expect(date).toMatch(/^\d{4}-\d{2}-\d{2}T/)); + result.forEach(({ alertTime, days }: { alertTime: string, days: number }) => { + expect(alertTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect([14, 7, 3]).toContain(days); + }); - const parsed = result.map((r: string | number | Date) => new Date(r)); const main = new Date(deadline); - const diffs = parsed.map((d: string | number) => Math.round((+main - +d) / (1000 * 60 * 60 * 24))); + const diffs = result.map(({ alertTime }: { alertTime: string }) => + Math.round((+main - +new Date(alertTime)) / (1000 * 60 * 60 * 24)) + ); expect(diffs).toEqual([14, 7, 3]); }); diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 9d75a119..1614c13d 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -164,7 +164,7 @@ export class GrantService { const params = { TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || 'TABLE_FAILURE', Key: { - grantId: grantId, + grantId: Number(grantId), }, }; @@ -457,6 +457,7 @@ export class GrantService { } try { + await this.deleteGrantNotifications(grantId); this.logger.debug(`Executing DynamoDB delete for grant ${grantId}`); await this.dynamoDb.delete(params).promise(); this.logger.log(`Successfully deleted grant ${grantId} from database`); @@ -483,14 +484,27 @@ export class GrantService { } } + // Deletes notifications associated with a particular grant + private async deleteGrantNotifications(grantId: number): Promise { + this.logger.log(`Deleting notifications for grant ${grantId}`); + const grant = await this.getGrantById(grantId); + const email = grant.bcan_poc.POC_email; + const notifications = await this.notificationService.getNotificationByUserEmail(email); + const grantNotifications = notifications.filter(n => n.notificationId.startsWith(`${grantId}-`)); + for (const notification of grantNotifications) { + await this.notificationService.deleteNotification(notification.notificationId); + } + this.logger.log(`Successfully deleted notifications for grant ${grantId}`); +} + // Calculates notification times for a deadline (14, 7, and 3 days before) - private getNotificationTimes(deadlineISO: string): string[] { + private getNotificationTimes(deadlineISO: string): { alertTime: string, days: number }[] { const deadline = new Date(deadlineISO); const daysBefore = [14, 7, 3]; return daysBefore.map(days => { const d = new Date(deadline); d.setDate(deadline.getDate() - days); - return d.toISOString(); + return { alertTime: d.toISOString(), days }; }); } @@ -507,13 +521,13 @@ export class GrantService { `Creating application deadline notifications for grant ${grantId} with deadline ${application_deadline}`, ); const alertTimes = this.getNotificationTimes(application_deadline); - for (const alertTime of alertTimes) { + for (const { alertTime, days } of alertTimes) { this.logger.debug( `Creating application notification for grant ${grantId} at alertTime ${alertTime}`, ); - const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`; + const message = `Application due in ${days} days for ${organization}`; const notification: Notification = { - notificationId: `${grantId}-app`, + notificationId: `${grantId}-app-${days}`, userEmail: email, message, alertTime: alertTime as TDateISO, @@ -532,15 +546,15 @@ export class GrantService { this.logger.debug( `Creating report deadline notifications for grant ${grantId} with ${report_deadlines.length} report_deadlines`, ); - for (const reportDeadline of report_deadlines) { + for (const [i, reportDeadline] of report_deadlines.entries()) { const alertTimes = this.getNotificationTimes(reportDeadline); - for (const alertTime of alertTimes) { + for (const {alertTime, days} of alertTimes) { this.logger.debug( `Creating report notification for grant ${grantId} at alertTime ${alertTime} (report deadline ${reportDeadline})`, ); - const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`; + const message = `Report due in ${days} days for ${organization}`; const notification: Notification = { - notificationId: `${grantId}-report`, + notificationId: `${grantId}-report-${i}-${days}`, userEmail: email, message, alertTime: alertTime as TDateISO, @@ -573,9 +587,9 @@ export class GrantService { `Updating application deadline notifications for grant ${grantId} with deadline ${application_deadline}`, ); const alertTimes = this.getNotificationTimes(application_deadline); - for (const alertTime of alertTimes) { - const notificationId = `${grantId}-app`; - const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`; + for (const { alertTime, days } of alertTimes) { + const notificationId = `${grantId}-app-${days}`; + const message = `Application due in ${days} days for ${organization}`; this.logger.debug( `Updating application notification ${notificationId} for grant ${grantId} to alertTime ${alertTime}`, @@ -596,11 +610,11 @@ export class GrantService { this.logger.debug( `Updating report deadline notifications for grant ${grantId} with ${report_deadlines.length} report_deadlines`, ); - for (const reportDeadline of report_deadlines) { + for (const [i, reportDeadline] of report_deadlines.entries()) { const alertTimes = this.getNotificationTimes(reportDeadline); - for (const alertTime of alertTimes) { - const notificationId = `${grantId}-report`; - const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`; + for (const { alertTime, days } of alertTimes) { + const notificationId = `${grantId}-report-${i}-${days}`; + const message = `Report due in ${days} days for ${organization}`; this.logger.debug( `Updating report notification ${notificationId} for grant ${grantId} to alertTime ${alertTime} (report deadline ${reportDeadline})`, From 00ca1f49d07d2a6fc738ffcb8802d3dfb798a45c Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 21:42:59 -0400 Subject: [PATCH 34/68] Updating tests --- .../__test__/cashflow-revenue.service.spec.ts | 12 ++++++------ frontend/src/sign-up/PasswordField.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts index 177a6fc2..34e721ab 100644 --- a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts +++ b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts @@ -385,12 +385,12 @@ describe('RevenueService', () => { mockPut.mockReturnValue(resolved({})); mockDelete.mockReturnValue(resolved({})); - const renamed = { ...mockRevenue, name: 'New Name' }; + const renamed = { ...mockRevenue, name: 'New Name 1' }; const result = await service.updateRevenue('Test Revenue', renamed); - expect(result.name).toBe('New Name'); + expect(result.name).toBe('New Name 1'); expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ - Item: expect.objectContaining({ name: 'New Name' }), + Item: expect.objectContaining({ name: 'New Name 1' }), })); expect(mockDelete).toHaveBeenCalledWith(expect.objectContaining({ Key: { name: 'Test Revenue' }, @@ -407,9 +407,9 @@ describe('RevenueService', () => { it('should use the route param name as the DynamoDB get key, not the body name', async () => { mockGet.mockReturnValue(resolved({ Item: mockRevenue })); mockPut.mockReturnValue(resolved({})); - await service.updateRevenue('Test Revenue', { ...mockRevenue, name: 'Different Name' }); + await service.updateRevenue('Test Revenue 2', { ...mockRevenue, name: 'Different Name' }); expect(mockGet).toHaveBeenCalledWith(expect.objectContaining({ - Key: { name: 'Test Revenue' }, + Key: { name: 'Test Revenue 2' }, })); }); @@ -418,7 +418,7 @@ describe('RevenueService', () => { mockPut.mockReturnValue(resolved({})); 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); }); diff --git a/frontend/src/sign-up/PasswordField.tsx b/frontend/src/sign-up/PasswordField.tsx index 98512594..67acef53 100644 --- a/frontend/src/sign-up/PasswordField.tsx +++ b/frontend/src/sign-up/PasswordField.tsx @@ -77,7 +77,7 @@ export default function PasswordField({ Date: Wed, 15 Apr 2026 22:09:08 -0400 Subject: [PATCH 35/68] Adding inactive coloring --- .../cash-flow/components/CashEditLineItem.tsx | 4 ++- .../cash-flow/components/CashSourceList.tsx | 4 ++- .../cash-flow/processCashflowData.ts | 25 +++++++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx index faf78dac..f6f546b5 100644 --- a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx +++ b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx @@ -10,6 +10,7 @@ type CashEditLineItemProps = { onRemove: () => Promise; isReadOnly: boolean; onReadOnlyAction?: () => void; + inactive?: boolean; }; export default function CashEditLineItem({ @@ -19,6 +20,7 @@ export default function CashEditLineItem({ onRemove, isReadOnly, onReadOnlyAction, + inactive = false, }: CashEditLineItemProps) { const [editing, setEditing] = useState(false); @@ -34,7 +36,7 @@ export default function CashEditLineItem({ }; return ( -
+
{!editing && (
diff --git a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index 624e7844..3c1eda59 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -12,6 +12,7 @@ import { formatDateByFrequency, frequencyLabels } from "../../../../../middle-la import { getAppStore } from "../../../external/bcanSatchel/store"; import { RevenueType } from "../../../../../middle-layer/types/RevenueType"; import { CostType } from "../../../../../middle-layer/types/CostType"; +import { isInactive } from "../processCashflowData"; type SourceProps = { type: "Revenue" | "Cost"; @@ -30,7 +31,7 @@ const formatInstallmentDate = (dateValue: Date | string) => { }; const CashSourceList = observer(({ type, lineItems }: SourceProps) => { - const { filterRevenueCategory, filterCostCategory } = getAppStore(); + const { filterRevenueCategory, filterCostCategory, cashflowSettings } = getAppStore(); const activeFilter = type === "Revenue" ? filterRevenueCategory : filterCostCategory; const filteredItems = activeFilter.length > 0 @@ -102,6 +103,7 @@ const CashSourceList = observer(({ type, lineItems }: SourceProps) => { } } }} + inactive={isInactive(item)} > {(onClose) => type === "Cost" ? ( diff --git a/frontend/src/main-page/cash-flow/processCashflowData.ts b/frontend/src/main-page/cash-flow/processCashflowData.ts index 3f8dcb39..e08458a0 100644 --- a/frontend/src/main-page/cash-flow/processCashflowData.ts +++ b/frontend/src/main-page/cash-flow/processCashflowData.ts @@ -8,6 +8,7 @@ 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"; // This has not been tested yet but the basic structure when implemented should be the same // Mirrored format for processGrantData.ts @@ -80,6 +81,22 @@ export const fetchCashflowSettings = async () => { } }; +export const isInactive = (item: CashflowRevenue | CashflowCost) => { + const { + cashflowSettings + } = getAppStore(); + const refDate = cashflowSettings?.startDate ? new Date(cashflowSettings.startDate) : new Date(); + if ('frequency' in item && 'date' in item && item.frequency === Frequency.OneTime) { + const itemDate = new Date(item.date); + return itemDate < refDate; + } + if ('installments' in item) { + const futureInstallments = item.installments.filter(installment => new Date(installment.date) >= refDate); + return futureInstallments.length === 0; + } + return false; + }; + // could contain callbacks for sorting and filtering line items // stores state for list of costs/revenues @@ -105,8 +122,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 }; }; From 4f5779c2615780f5c6602344e19cd25738282bfa Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 22:19:33 -0400 Subject: [PATCH 36/68] Fixing nan --- frontend/src/main-page/cash-flow/CashFlowPage.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index 60294dd8..8695d883 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -27,12 +27,12 @@ const CashFlowPage = observer(() => { const { costs, revenues, cashflowSettings } = ProcessCashflowData(); const { chartData, kpis } = buildCashflowProjection(revenues, costs, { - startingCash: cashflowSettings?.startingCash ?? 0, - salaryIncrease: cashflowSettings?.salaryIncrease ?? 0, - benefitsIncrease: cashflowSettings?.benefitsIncrease ?? 0, - startDate: - cashflowSettings?.startDate ?? - (new Date().toISOString().split("T")[0] as TDateISO), + startingCash: Number.isNaN(cashflowSettings?.startingCash) ? 0 : cashflowSettings?.startingCash ?? 0, + salaryIncrease: Number.isNaN(cashflowSettings?.salaryIncrease) ? 0 : cashflowSettings?.salaryIncrease ?? 0, + benefitsIncrease: Number.isNaN(cashflowSettings?.benefitsIncrease) ? 0 : cashflowSettings?.benefitsIncrease ?? 0, + startDate: cashflowSettings?.startDate === undefined + ? (new Date().toISOString().split("T")[0] as TDateISO) + : cashflowSettings?.startDate ?? (new Date().toISOString().split("T")[0] as TDateISO), }); return ( @@ -41,7 +41,7 @@ const CashFlowPage = observer(() => { {/* Row 1 */} From 503d6c22b8e9492bc89dbea7c941efb038c08613 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 22:22:43 -0400 Subject: [PATCH 37/68] Fixing empty date --- frontend/src/main-page/cash-flow/CashFlowPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index 8695d883..44c88fcf 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -30,7 +30,7 @@ const CashFlowPage = observer(() => { startingCash: Number.isNaN(cashflowSettings?.startingCash) ? 0 : cashflowSettings?.startingCash ?? 0, salaryIncrease: Number.isNaN(cashflowSettings?.salaryIncrease) ? 0 : cashflowSettings?.salaryIncrease ?? 0, benefitsIncrease: Number.isNaN(cashflowSettings?.benefitsIncrease) ? 0 : cashflowSettings?.benefitsIncrease ?? 0, - startDate: cashflowSettings?.startDate === undefined + startDate: cashflowSettings?.startDate === "" as TDateISO ? (new Date().toISOString().split("T")[0] as TDateISO) : cashflowSettings?.startDate ?? (new Date().toISOString().split("T")[0] as TDateISO), }); From 32432b874b60dc35477f9e292b20bc200b632dca Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 22:50:27 -0400 Subject: [PATCH 38/68] Fixing axes --- .../components/CashProjectionChart.tsx | 66 +++++++++++++++---- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx index 96cbc712..154e1dff 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx @@ -15,18 +15,54 @@ type ProjectionProps = { data: ChartDataPoint[]; }; -const formatMonthYear = (timestamp: number): string => { - const d = new Date(timestamp); - return `${d.getMonth() + 1}/${d.getFullYear()}`; +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); + } + + ticks.filter((_, i) => i % 4 === 0) + + return ticks; }; 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 (
{ - `$${(v / 1000).toFixed(0)}k`} @@ -86,7 +122,9 @@ const CashProjectionChart = observer(({ data }: ProjectionProps) => { textAlign: "left", }} labelFormatter={formatMonthYear} - formatter={(value: number) => `$${value.toLocaleString("en-US", { minimumFractionDigits: 2 })}`} + formatter={(value: number) => + `$${value.toLocaleString("en-US", { minimumFractionDigits: 2 })}` + } /> From 23bf4a27b7cae7d2d3d24a34adb82fed177e76ef Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 22:52:00 -0400 Subject: [PATCH 39/68] Removing unused variable --- frontend/src/main-page/cash-flow/components/CashSourceList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index 3c1eda59..f40c8d87 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -31,7 +31,7 @@ const formatInstallmentDate = (dateValue: Date | string) => { }; const CashSourceList = observer(({ type, lineItems }: SourceProps) => { - const { filterRevenueCategory, filterCostCategory, cashflowSettings } = getAppStore(); + const { filterRevenueCategory, filterCostCategory } = getAppStore(); const activeFilter = type === "Revenue" ? filterRevenueCategory : filterCostCategory; const filteredItems = activeFilter.length > 0 From 496374be852df3471cfc328fdd9b9fa50f1a7c95 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 23:18:32 -0400 Subject: [PATCH 40/68] Preserve axis start --- .../src/main-page/cash-flow/components/CashProjectionChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx index 154e1dff..5b39d09f 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx @@ -102,6 +102,7 @@ const normalizedData = data.map(d => ({ axisLine={true} tickLine={true} tickFormatter={formatMonthYear} + interval="equidistantPreserveStart" tick={{ fontSize: 12, dy: 10, textAnchor: "middle" }} className="axis" /> From afb443af24b7b2ba98517462315bd89f58fc913e Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 23:25:40 -0400 Subject: [PATCH 41/68] Fixing axis and removing logs --- .../cash-flow/components/CashProjectionChart.tsx | 8 +++++--- frontend/src/main-page/cash-flow/projection.ts | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx index 5b39d09f..cfe829c8 100644 --- a/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx +++ b/frontend/src/main-page/cash-flow/components/CashProjectionChart.tsx @@ -39,9 +39,11 @@ const generateMonthlyTicks = (data: ChartDataPoint[]) => { current.setMonth(current.getMonth() + 1); } - ticks.filter((_, i) => i % 4 === 0) + const filteredTicks = ticks.filter((_, i) => i % 6 === 0) - return ticks; + console.log("Generated ticks:", filteredTicks.map(formatMonthYear)); + + return filteredTicks; }; const CashProjectionChart = observer(({ data }: ProjectionProps) => { @@ -102,7 +104,7 @@ const normalizedData = data.map(d => ({ axisLine={true} tickLine={true} tickFormatter={formatMonthYear} - interval="equidistantPreserveStart" + interval="preserveStart" tick={{ fontSize: 12, dy: 10, textAnchor: "middle" }} className="axis" /> diff --git a/frontend/src/main-page/cash-flow/projection.ts b/frontend/src/main-page/cash-flow/projection.ts index 8009ad1a..23c48996 100644 --- a/frontend/src/main-page/cash-flow/projection.ts +++ b/frontend/src/main-page/cash-flow/projection.ts @@ -109,10 +109,8 @@ export function buildCashflowProjection( // ---- Distribute revenues into monthly buckets ---- for (const rev of revenues) { for (const installment of rev.installments) { - console.log(`Processing revenue installment of $${installment.amount} on ${installment.date}`); const key = toMonthKey(installment.date); if (revenueBuckets.has(key) && new Date(installment.date) >= new Date(settings.startDate)) { - console.log(`Adding revenue installment of $${installment.amount} to month ${key}`); revenueBuckets.set(key, revenueBuckets.get(key)! + installment.amount); } } From 92b46e1b494afbb550224c8853e1746dee89fc5e Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 23:33:01 -0400 Subject: [PATCH 42/68] Fixing inactive grant --- .../cash-flow/processCashflowData.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/frontend/src/main-page/cash-flow/processCashflowData.ts b/frontend/src/main-page/cash-flow/processCashflowData.ts index e08458a0..1c969d2d 100644 --- a/frontend/src/main-page/cash-flow/processCashflowData.ts +++ b/frontend/src/main-page/cash-flow/processCashflowData.ts @@ -9,6 +9,7 @@ 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 @@ -82,20 +83,27 @@ export const fetchCashflowSettings = async () => { }; export const isInactive = (item: CashflowRevenue | CashflowCost) => { - const { - cashflowSettings - } = getAppStore(); - const refDate = cashflowSettings?.startDate ? new Date(cashflowSettings.startDate) : new Date(); - if ('frequency' in item && 'date' in item && item.frequency === Frequency.OneTime) { - const itemDate = new Date(item.date); - return itemDate < refDate; - } - if ('installments' in item) { - const futureInstallments = item.installments.filter(installment => new Date(installment.date) >= refDate); - return futureInstallments.length === 0; - } - return false; - }; + 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 From 3503de7bb1444d5bff7be82554d3f4fc4ed3101d Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Wed, 15 Apr 2026 23:48:08 -0400 Subject: [PATCH 43/68] Spacing fix --- frontend/src/main-page/users/components/UserMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/main-page/users/components/UserMenu.tsx b/frontend/src/main-page/users/components/UserMenu.tsx index 71979730..6979c13b 100644 --- a/frontend/src/main-page/users/components/UserMenu.tsx +++ b/frontend/src/main-page/users/components/UserMenu.tsx @@ -42,7 +42,7 @@ const UserMenu = ({ user }: UserMenuProps) => { boldSubtitle={user.email} warningMessage="If you delete this user, they will be permanently removed from the system." /> -
+
-
+
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(
From 8d44913776d7cc1b5b581c58ebe06d8854c31a5c Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Fri, 17 Apr 2026 12:45:32 -0400 Subject: [PATCH 52/68] Adding keys --- frontend/src/main-page/cash-flow/components/CashSourceList.tsx | 2 +- frontend/src/main-page/grants/GrantPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx index f40c8d87..307a6b3c 100644 --- a/frontend/src/main-page/cash-flow/components/CashSourceList.tsx +++ b/frontend/src/main-page/cash-flow/components/CashSourceList.tsx @@ -57,7 +57,7 @@ const CashSourceList = observer(({ type, lineItems }: SourceProps) => { return (
-
{item.type}
diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 13b46d22..e6b32890 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -139,7 +139,7 @@ function GrantPage() { ))}
- +
) : (
No grants found. From 2b652823438b337b46306889071c6a091c923fe7 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Fri, 17 Apr 2026 12:50:51 -0400 Subject: [PATCH 53/68] Making contact card observable --- .../grants/grant-view/components/ContactCard.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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; From c5f38fa1c287ff88bfbe164c806923671910ca14 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Fri, 17 Apr 2026 13:10:38 -0400 Subject: [PATCH 54/68] Saving button text cost/revenue --- frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx | 2 +- .../src/main-page/cash-flow/components/CashAddEditRevenue.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index 9b1bd263..540c47ad 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -269,7 +269,7 @@ export default function CashAddEditCost({ ) : null} {!costItem ? (
- + }} + className="border-grey-500" + />
-
- ); - }; +
+ ); +}; - export default ActionConfirmation; \ No newline at end of file +export default ActionConfirmation; diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx index 7b8d159b..ec90c72d 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx @@ -16,6 +16,7 @@ import { toInstallment, } from "../processCashflowDataEditSave"; import { formatMoney } from "../CashFlowPage"; +import ActionConfirmation from "../../../components/ActionConfirmation"; type FieldErrors = { type?: string; @@ -104,6 +105,10 @@ export default function CashAddEditRevenue({ const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingRevenue, setPendingRevenue] = useState( + null, + ); const showSuccessMessage = (message: string) => { setSuccessMessage(message); @@ -178,12 +183,26 @@ export default function CashAddEditRevenue({ setErrors({}); } - const handleSubmit = async () => { + const requestSubmit = () => { + console.log("Requesting submit with values:", { + type, + name, + singleInstallment, + installments, + isMultipleInstallments, + }); setSuccessMessage(null); const payload = buildPayload(); if (!payload) { return; } + setPendingRevenue(payload); + setShowConfirmModal(true); + }; + + const handleConfirmedSubmit = async () => { + if (!pendingRevenue) return; + const payload = pendingRevenue; setIsSubmitting(true); setErrors((previous) => ({ ...previous, submit: undefined })); @@ -264,9 +283,35 @@ export default function CashAddEditRevenue({ return (
{!isEditing && ( -
+
+ { + setShowConfirmModal(false); + setPendingRevenue(null); + }} + onConfirmDelete={() => { + void handleConfirmedSubmit(); + setPendingRevenue(null); + }} + title={revenueItem ? "Update revenue source" : "Create revenue source"} + subtitle={ + revenueItem + ? "Are you sure you want to save changes to" + : "Are you sure you want to add" + } + boldSubtitle={pendingRevenue?.name ?? revenueItem?.name ?? ""} + warningMessage={ + revenueItem + ? "This will update this revenue line in your cash flow." + : "This will create a new revenue line in your cash flow." + } + variant={revenueItem ? "update" : "create"} + /> +
{"Add Revenue Source"}
+
)}
@@ -372,7 +417,7 @@ export default function CashAddEditRevenue({ {!isEditing ? (
@@ -222,8 +223,29 @@ const EditGrant: React.FC<{ subtitle={"Are you sure you want to delete"} boldSubtitle={form.organization} warningMessage="If you delete this grant, it will be permanently removed from the system." + variant="delete" />
)} + setShowSaveModal(false)} + onConfirmDelete={() => { + handleSubmit(); + }} + title={grantToEdit ? "Save Grant" : "Create Grant"} + subtitle={ + grantToEdit + ? "Are you sure you want to save changes to" + : "Are you sure you want to create a grant for" + } + boldSubtitle={form.organization} + warningMessage={ + grantToEdit + ? "Saving will update this grant's details in the system." + : "A new grant will be added to the system with these details." + } + variant={grantToEdit ? "update" : "create"} + />
{/* Error Popup */} diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx index a7370102..fb32191b 100644 --- a/frontend/src/main-page/navbar/NavBar.tsx +++ b/frontend/src/main-page/navbar/NavBar.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { clearAllFilters, @@ -11,6 +12,7 @@ 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 ActionConfirmation from "../../components/ActionConfirmation"; const tabs: NavTabProps[] = [ { name: "Dashboard", linkTo: "/main/dashboard", icon: faChartLine }, @@ -24,15 +26,28 @@ const NavBar: React.FC = observer(() => { const navigate = useNavigate(); const user = getAppStore().user; const isAdmin = user?.position === UserStatus.Admin; + const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false); - const handleLogout = async () => { + const performLogout = async () => { logoutUser(); clearAllFilters(); navigate("/login"); }; - + return (
, - document.body + , + document.body, ); -}); + }, +); -export default NotificationPopup; \ No newline at end of file +export default NotificationPopup; diff --git a/frontend/src/main-page/settings/ChangePasswordModal.tsx b/frontend/src/main-page/settings/ChangePasswordModal.tsx index 0d9d58d3..a5e6e7da 100644 --- a/frontend/src/main-page/settings/ChangePasswordModal.tsx +++ b/frontend/src/main-page/settings/ChangePasswordModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { @@ -6,6 +6,7 @@ import { PasswordRequirements, isPasswordValid, } from "../../sign-up"; +import ActionConfirmation from "../../components/ActionConfirmation"; import Button from "../../components/Button"; export type ChangePasswordFormValues = { @@ -29,6 +30,11 @@ export default function ChangePasswordModal({ const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [reEnterPassword, setReEnterPassword] = useState(""); + const [showConfirm, setShowConfirm] = useState(false); + + useEffect(() => { + if (!isOpen) setShowConfirm(false); + }, [isOpen]); if (!isOpen) return null; @@ -47,11 +53,16 @@ export default function ChangePasswordModal({ setCurrentPassword(""); setNewPassword(""); setReEnterPassword(""); + setShowConfirm(false); onClose(); }; - const handleSave = () => { + const requestSave = () => { if (!canSave) return; + setShowConfirm(true); + }; + + const handleConfirmedSave = () => { onSubmit?.({ currentPassword: currentPassword.trim(), newPassword, @@ -65,6 +76,16 @@ export default function ChangePasswordModal({ aria-modal="true" aria-labelledby="change-password-title" > + setShowConfirm(false)} + onConfirmDelete={handleConfirmedSave} + title="Change password" + subtitle="Are you sure you want to change" + boldSubtitle="your password" + warningMessage="You will use your new password the next time you sign in." + variant="update" + />

)} -

diff --git a/frontend/src/main-page/settings/ProfilePictureModal.tsx b/frontend/src/main-page/settings/ProfilePictureModal.tsx index 7ade673f..a80c8325 100644 --- a/frontend/src/main-page/settings/ProfilePictureModal.tsx +++ b/frontend/src/main-page/settings/ProfilePictureModal.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import Cropper, { Area } from "react-easy-crop"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; @@ -15,6 +15,7 @@ import { getAppStore } from "../../external/bcanSatchel/store"; import { updateUserProfile } from "../../external/bcanSatchel/actions"; import { setActiveUsers } from "../../external/bcanSatchel/actions"; import { User } from "../../../../middle-layer/types/User"; +import ActionConfirmation from "../../components/ActionConfirmation"; type ProfilePictureModalProps = { isOpen: boolean; @@ -36,9 +37,14 @@ export default function ProfilePictureModal({ const [uploadError, setUploadError] = useState(null); const [isUploading, setIsUploading] = useState(false); const [validationError, setValidationError] = useState(null); + const [showUploadConfirm, setShowUploadConfirm] = useState(false); const user = getAppStore().user; + useEffect(() => { + if (!isOpen) setShowUploadConfirm(false); + }, [isOpen]); + const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { setCroppedAreaPixels(croppedAreaPixels); }, []); @@ -51,6 +57,7 @@ export default function ProfilePictureModal({ setUploadError(null); setValidationError(null); setIsUploading(false); + setShowUploadConfirm(false); onClose(); }; @@ -82,7 +89,7 @@ export default function ProfilePictureModal({ reader.readAsDataURL(file); }; - const handleSave = async () => { + const performUpload = async () => { if (!imageSrc || !croppedAreaPixels || !user) return; setIsUploading(true); @@ -152,6 +159,18 @@ export default function ProfilePictureModal({ aria-modal="true" aria-labelledby="profile-picture-title" > + setShowUploadConfirm(false)} + onConfirmDelete={() => { + void performUpload(); + }} + title="Update profile picture" + subtitle="Are you sure you want to upload" + boldSubtitle="this profile picture" + warningMessage="This will replace your current profile picture for your account." + variant="update" + />

@@ -229,6 +235,34 @@ function Settings() { onSuccess={() => setProfilePictureMessage({ type: "success", text: "Profile picture updated." })} onError={(msg) => setProfilePictureMessage({ type: "error", text: msg })} /> + setIsRemoveProfilePicModalOpen(false)} + onConfirmDelete={() => { + handleRemoveProfilePic(); + }} + title="Remove Profile Picture" + subtitle="Are you sure you want to remove your" + boldSubtitle="profile picture" + warningMessage="Your profile picture will be removed and replaced with the default avatar." + variant="delete" + /> + setIsSaveProfileModalOpen(false)} + onConfirmDelete={() => { + handleSaveEdit(); + }} + title="Save Profile Changes" + subtitle="Are you sure you want to save changes to your" + boldSubtitle="profile information" + warningMessage={ + isEmailChanged + ? "Changing your email will also change the email you use to log in." + : "Your personal information will be updated in the system." + } + variant="update" + />
)} From c65e9dcb1d3f2e7fafd74b1a89ae039eb58b604b Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 00:32:46 -0400 Subject: [PATCH 59/68] Adding back lyanne fixes --- .../components/CashCategoryDropdown.tsx | 2 +- .../components/CostBenefitAnalysis.tsx | 84 +++++++++++++++---- frontend/src/main-page/settings/Settings.tsx | 1 - 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx b/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx index 070ffceb..280ba40d 100644 --- a/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx +++ b/frontend/src/main-page/cash-flow/components/CashCategoryDropdown.tsx @@ -76,7 +76,7 @@ export default function CashCategoryDropdown({ aria-expanded={isOpen} aria-label={name} > - {selectedLabel} + {selectedLabel} = ({ }) => { 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/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index cc4f3389..c20bc72e 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -113,7 +113,6 @@ function Settings() { updateUserProfile(updatedUser); setPersonalInfo(editForm); await fetchGrants(); - await fetchGrants(); setIsEditingPersonalInfo(false); setPersonalInfoError(null); From 3c9bfeb35b8207b1c71f187b8af30f7bdc818a3d Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 00:54:52 -0400 Subject: [PATCH 60/68] Squashed commit of the following: commit 5a9c13099df62543b136a262e128832eca52f718 Author: Aditya Pathak <138696171+adityapat24@users.noreply.github.com> Date: Sat Apr 18 00:34:03 2026 -0400 changed colors and added missing confirmations (#431) * changed colors and added missing confirmations * Styling fixes * prioritize adding errors over action confirmation modal * Updating yellow --------- Co-authored-by: Jane Kamata Co-authored-by: lyannne commit 2f0ce918254a804cb3dfc98447ae87a5cb7f3651 Merge: e604a40 847520a Author: Aditya Pathak <138696171+adityapat24@users.noreply.github.com> Date: Fri Apr 17 15:38:12 2026 -0400 Merge pull request #400 from Code-4-Community/397-dev-adding-more-action-confirmations Adding Action Confirmations commit 847520afaf2a192de79d1cb09614254c834f3832 Merge: b3be17b e604a40 Author: Aditya Pathak <138696171+adityapat24@users.noreply.github.com> Date: Thu Apr 16 21:58:52 2026 -0400 Merge branch 'main' into 397-dev-adding-more-action-confirmations commit b3be17b428207f430a9e1b8df119b04fb65c61da Author: lyannne Date: Thu Apr 16 21:42:44 2026 -0400 white background commit e604a40affa01bd475142f3ebef4d768556d0913 Merge: 5e66036 e7803ec Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Thu Apr 16 21:36:56 2026 -0400 Merge pull request #401 from Code-4-Community/396-dev-user-profile-updates-side-effects update user profile updates side effects commit ceaadd4acbe90bdb424f99b9a9221c1aa2d1f3c7 Merge: b2fabd4 5e66036 Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Wed Apr 15 22:26:16 2026 -0400 Merge branch 'main' into 397-dev-adding-more-action-confirmations commit 5e6603621d7531a6bbfd3b4be9f99a13b23b8b8d Merge: 8dec21b fefdad9 Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Wed Apr 15 18:09:59 2026 -0400 Merge pull request #429 from Code-4-Community/bug-fix-filters Fixing filters bug commit fefdad942d5048c1110615a3e2cbb8d6e0c6962a Author: Jane Kamata Date: Wed Apr 15 14:15:50 2026 -0400 Fixing filters bug commit e7803ec6cb83fe97bc9f515065a4d27d06ed3a5f Merge: e68bab4 8dec21b Author: prooflesben Date: Wed Apr 15 13:26:37 2026 -0400 Merge branch 'main' into 396-dev-user-profile-updates-side-effects commit 8dec21be93ed37bc8c712331417302dc77ec3c30 Merge: 6490d98 5d56537 Author: prooflesben <122566738+prooflesben@users.noreply.github.com> Date: Wed Apr 15 12:53:24 2026 -0400 Merge pull request #391 from Code-4-Community/382-dev---update-delete-grant-so-it-notifications-as-well update delete grant so it deletes notifs too commit e68bab4f78d345ddb83516bc33cf682716a45e76 Merge: 1f208b2 10fb2cd Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Mon Apr 13 13:22:05 2026 -0400 Merge branch 'main' into 396-dev-user-profile-updates-side-effects commit b2fabd45c90f32f5d79e283005600218a6edf0a4 Merge: e2779c8 10fb2cd Author: Jane Kamata <38163243+janekamata@users.noreply.github.com> Date: Mon Apr 13 13:21:20 2026 -0400 Merge branch 'main' into 397-dev-adding-more-action-confirmations commit e2779c8b12bd4fcaf1e0e16c2378c1236d45bb26 Merge: 28ebeba 2202e50 Author: adityapat24 Date: Sun Apr 12 16:50:23 2026 -0400 Merge branch 'main' of https://github.com/Code-4-Community/bcan into 397-dev-adding-more-action-confirmations commit 28ebeba502a7fd4b32747e1e18b1b66b6735eb43 Author: adityapat24 Date: Sun Apr 12 16:43:34 2026 -0400 action confirmations for everything and adding in colors commit 3acea04f2970478d8bb9871eb771e741bb198b68 Merge: ff6e87e 08a77c8 Author: adityapat24 Date: Sun Apr 12 16:24:02 2026 -0400 Merge branch 'main' of https://github.com/Code-4-Community/bcan into 397-dev-adding-more-action-confirmations commit 1f208b2fdaf6d8fbaba99219e9a0f1eae178058a Author: Camila Carrillo Date: Thu Apr 9 18:31:57 2026 -0400 fixed code and tests commit 710a756b09a37880b01d6876f1fd7fcfbefb40ab Author: Camila Carrillo Date: Thu Apr 9 17:46:42 2026 -0400 update user profile updates side effects commit ff6e87e74670ebe533c42037171e3d009393de07 Author: adityapat24 Date: Thu Apr 9 15:15:43 2026 -0400 confirmation for needed actions commit 5d565375ca093021abc621386f8af2fe6885eb43 Author: Camila Carrillo Date: Fri Apr 3 20:17:31 2026 -0400 updated tests commit ba316f264245ca6792704796a0024acab64c8044 Author: Camila Carrillo Date: Fri Apr 3 19:29:31 2026 -0400 update delete grant so it deletes notifs too --- .../src/components/ActionConfirmation.tsx | 38 +++++++++------- .../cash-flow/components/CashAddEditCost.tsx | 44 ++++++++++++++++--- .../components/CashAddEditRevenue.tsx | 12 +++++ .../main-page/grants/edit-grant/EditGrant.tsx | 16 ++++++- .../notifications/NotificationPopup.tsx | 4 +- .../settings/ChangePasswordModal.tsx | 2 +- .../settings/ProfilePictureModal.tsx | 2 +- frontend/src/main-page/settings/Settings.tsx | 2 +- frontend/src/styles/index.css | 3 +- frontend/tailwind.config.ts | 2 +- 10 files changed, 96 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/ActionConfirmation.tsx b/frontend/src/components/ActionConfirmation.tsx index 59e847dd..04cc23e2 100644 --- a/frontend/src/components/ActionConfirmation.tsx +++ b/frontend/src/components/ActionConfirmation.tsx @@ -28,7 +28,7 @@ const ActionConfirmation = ({ const styles = variant === "create" ? { - panel: "border-t-4 border-green bg-green-light/30", + panel: "border-t-4 border-green", stripe: "bg-green", box: "bg-green-light", Icon: FaCheckCircle, @@ -37,23 +37,27 @@ const ActionConfirmation = ({ labelClass: "text-green", textClass: "text-green-dark", cancelClass: - "text-grey-700 border-grey-500 hover:border-grey-600 hover:bg-grey-150 active:bg-grey-200", + "!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", + confirmClass: + "!border-0 bg-green text-white hover:!border-green hover:bg-green-dark active:bg-green-dark active:!bg-opacity-75", } : variant === "update" ? { - panel: "border-t-4 border-grey-400 bg-grey-150", - stripe: "bg-grey-500", - box: "bg-grey-200", + panel: "border-t-4 border-yellow", + stripe: "bg-yellow", + box: "bg-yellow-light", Icon: FaInfoCircle, - iconClass: "text-grey-700", + iconClass: "text-yellow-dark", label: "Review", - labelClass: "text-grey-800", - textClass: "text-grey-800", + labelClass: "text-yellow-dark", + textClass: "text-yellow-dark", cancelClass: - "text-grey-700 border-grey-500 hover:border-grey-600 hover:bg-grey-200 active:bg-grey-300", + "!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", + confirmClass: + "!border-0 bg-yellow text-white hover:!border-yellow hover:bg-opacity-75 active:bg-yellow", } : { - panel: "border-t-4 border-red bg-red-lightest/40", + panel: "border-t-4 border-red", stripe: "bg-red", box: "bg-red-light", Icon: IoIosWarning, @@ -62,7 +66,9 @@ const ActionConfirmation = ({ labelClass: "text-red", textClass: "text-red", cancelClass: - "text-red border-red hover:border-red hover:bg-red-light active:bg-red", + "!border-0 bg-red text-white hover:!border-red hover:bg-opacity-75 active:bg-red", + 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", }; const { Icon } = styles; @@ -73,7 +79,7 @@ const ActionConfirmation = ({ onClick={onCloseDelete} >
e.stopPropagation()} >

{title}

@@ -86,11 +92,11 @@ const ActionConfirmation = ({
-
+
- -

+ +

{styles.label}

@@ -111,7 +117,7 @@ const ActionConfirmation = ({ onConfirmDelete(); onCloseDelete(); }} - className="border-grey-500" + className={styles.confirmClass} />
diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index 540c47ad..8f8fbad5 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -8,6 +8,7 @@ import { createNewCost, saveCostEdits } from "../processCashflowDataEditSave"; import { Frequency, frequencyIntervalsInMonths } from "../../../../../middle-layer/types/Frequency"; import { TDateISO } from "../../../../../backend/src/utils/date"; import { getAppStore } from "../../../external/bcanSatchel/store"; +import ActionConfirmation from "../../../components/ActionConfirmation"; type FieldErrors = { type?: string; @@ -52,6 +53,8 @@ export default function CashAddEditCost({ const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingCost, setPendingCost] = useState(null); const showSuccessMessage = (message: string) => { setSuccessMessage(message); @@ -120,18 +123,25 @@ export default function CashAddEditCost({ setErrors({}); }; - const handleSubmit = async () => { + const requestConfirm = () => { setSuccessMessage(null); const payload = buildPayload(); if (!payload) { return; } + setPendingCost(payload); + setShowConfirmModal(true); + }; + + const handleConfirmedSubmit = async () => { + if (!pendingCost) return; + const payload = pendingCost; setIsSubmitting(true); setErrors((previous) => ({ ...previous, submit: undefined })); const result = costItem - ? await saveCostEdits(payload, costItem!.name) + ? await saveCostEdits(payload, costItem.name) : await createNewCost(payload); if (!result.success) { setErrors((previous) => ({ @@ -154,6 +164,30 @@ export default function CashAddEditCost({ return (
+ { + setShowConfirmModal(false); + setPendingCost(null); + }} + onConfirmDelete={() => { + void handleConfirmedSubmit(); + setPendingCost(null); + }} + title={costItem ? "Update Cost Source" : "Create Cost Source"} + subtitle={ + costItem + ? "Are you sure you want to save changes to" + : "Are you sure you want to add" + } + boldSubtitle={pendingCost?.name ?? costItem?.name ?? ""} + warningMessage={ + costItem + ? "This will update this cost line in your cash flow." + : "This will create a new cost line in your cash flow." + } + variant={costItem ? "update" : "create"} + /> {!costItem && (
{"Add Cost Source"} @@ -270,9 +304,9 @@ export default function CashAddEditCost({ {!costItem ? (
diff --git a/frontend/src/main-page/notifications/NotificationPopup.tsx b/frontend/src/main-page/notifications/NotificationPopup.tsx index 3e17ec7a..c667bfd4 100644 --- a/frontend/src/main-page/notifications/NotificationPopup.tsx +++ b/frontend/src/main-page/notifications/NotificationPopup.tsx @@ -81,8 +81,8 @@ const NotificationPopup: React.FC = observer( }} title={ confirm.kind === "all" - ? "Delete all notifications" - : "Delete notification" + ? "Delete All Notifications" + : "Delete Notification" } subtitle="Are you sure you want to delete" boldSubtitle={ diff --git a/frontend/src/main-page/settings/ChangePasswordModal.tsx b/frontend/src/main-page/settings/ChangePasswordModal.tsx index a5e6e7da..72012fd3 100644 --- a/frontend/src/main-page/settings/ChangePasswordModal.tsx +++ b/frontend/src/main-page/settings/ChangePasswordModal.tsx @@ -80,7 +80,7 @@ export default function ChangePasswordModal({ isOpen={showConfirm} onCloseDelete={() => setShowConfirm(false)} onConfirmDelete={handleConfirmedSave} - title="Change password" + title="Change Password" subtitle="Are you sure you want to change" boldSubtitle="your password" warningMessage="You will use your new password the next time you sign in." diff --git a/frontend/src/main-page/settings/ProfilePictureModal.tsx b/frontend/src/main-page/settings/ProfilePictureModal.tsx index a80c8325..7203b124 100644 --- a/frontend/src/main-page/settings/ProfilePictureModal.tsx +++ b/frontend/src/main-page/settings/ProfilePictureModal.tsx @@ -165,7 +165,7 @@ export default function ProfilePictureModal({ onConfirmDelete={() => { void performUpload(); }} - title="Update profile picture" + title="Update Profile Picture" subtitle="Are you sure you want to upload" boldSubtitle="this profile picture" warningMessage="This will replace your current profile picture for your account." diff --git a/frontend/src/main-page/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index c20bc72e..52cba526 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -13,8 +13,8 @@ import ChangePasswordModal, { ChangePasswordFormValues } from "./ChangePasswordM import { getAppStore } from "../../external/bcanSatchel/store"; import { setActiveUsers, updateUserProfile } from "../../external/bcanSatchel/actions"; import { User } from "../../../../middle-layer/types/User"; -import { fetchGrants } from "../grants/filter-bar/processGrantData"; import ActionConfirmation from "../../components/ActionConfirmation"; +import { fetchGrants } from "../grants/filter-bar/processGrantData"; import { InputField } from "../../sign-up"; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index c72dc773..f90c7050 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -34,7 +34,8 @@ --color-blue: #006ca1; --color-blue-light: #d0edff; - --color-yellow-dark: #9b6000; + --color-yellow-dark: #9B6000; + --color-yellow: #B87F2C; --color-yellow-light: #fff0d3; --color-red-dark: #c80000; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index d889a7b3..de543b22 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -18,7 +18,7 @@ export default { yellow: { dark: "#9B6000", - DEFAULT: "#9B6000", + DEFAULT: "#B87F2C", light: "#FFF0D3", }, From 9a9884043088998f145c7e327023a3030aa2e3ef Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 00:57:17 -0400 Subject: [PATCH 61/68] Fixing revenue errors --- .../cash-flow/components/CashAddEditRevenue.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx index 7d58ceb8..986c5409 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx @@ -109,10 +109,6 @@ export default function CashAddEditRevenue({ const [pendingRevenue, setPendingRevenue] = useState( null, ); - const [showConfirmModal, setShowConfirmModal] = useState(false); - const [pendingRevenue, setPendingRevenue] = useState( - null, - ); const showSuccessMessage = (message: string) => { setSuccessMessage(message); @@ -187,7 +183,6 @@ export default function CashAddEditRevenue({ setErrors({}); }; - const requestSubmit = () => { const requestSubmit = () => { setSuccessMessage(null); const payload = buildPayload(); @@ -198,13 +193,6 @@ export default function CashAddEditRevenue({ setShowConfirmModal(true); }; - const handleConfirmedSubmit = async () => { - if (!pendingRevenue) return; - const payload = pendingRevenue; - setPendingRevenue(payload); - setShowConfirmModal(true); - }; - const handleConfirmedSubmit = async () => { if (!pendingRevenue) return; const payload = pendingRevenue; From ec7e8c8be010320ad54c6ca1727b7d73bb56040d Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 01:05:32 -0400 Subject: [PATCH 62/68] Edit revenue confirmation --- .../components/CashAddEditRevenue.tsx | 89 ++++++++++++++++--- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx index e31f6854..7d58ceb8 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx @@ -9,7 +9,14 @@ import CashCategoryDropdown from "./CashCategoryDropdown"; import CashRevenueInstallment, { EditableInstallment, } from "./CashRevenueInstallment"; -import { createNewRevenue, isValidInstallment, toInstallment } from "../../cash-flow/processCashflowDataEditSave"; +import { + createNewRevenue, + isValidInstallment, + saveRevenueEdits, + toInstallment, +} from "../processCashflowDataEditSave"; +import { formatMoney } from "../CashFlowPage"; +import ActionConfirmation from "../../../components/ActionConfirmation"; type FieldErrors = { type?: string; @@ -102,6 +109,10 @@ export default function CashAddEditRevenue({ const [pendingRevenue, setPendingRevenue] = useState( null, ); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingRevenue, setPendingRevenue] = useState( + null, + ); const showSuccessMessage = (message: string) => { setSuccessMessage(message); @@ -176,6 +187,7 @@ export default function CashAddEditRevenue({ setErrors({}); }; + const requestSubmit = () => { const requestSubmit = () => { setSuccessMessage(null); const payload = buildPayload(); @@ -186,6 +198,13 @@ export default function CashAddEditRevenue({ setShowConfirmModal(true); }; + const handleConfirmedSubmit = async () => { + if (!pendingRevenue) return; + const payload = pendingRevenue; + setPendingRevenue(payload); + setShowConfirmModal(true); + }; + const handleConfirmedSubmit = async () => { if (!pendingRevenue) return; const payload = pendingRevenue; @@ -271,9 +290,37 @@ export default function CashAddEditRevenue({ return (
-
- {"Add Revenue Source"} -
+ { + setShowConfirmModal(false); + setPendingRevenue(null); + }} + onConfirmDelete={() => { + void handleConfirmedSubmit(); + setPendingRevenue(null); + }} + title={revenueItem ? "Update revenue source" : "Create revenue source"} + subtitle={ + revenueItem + ? "Are you sure you want to save changes to" + : "Are you sure you want to add" + } + boldSubtitle={pendingRevenue?.name ?? revenueItem?.name ?? ""} + warningMessage={ + revenueItem + ? "This will update this revenue line in your cash flow." + : "This will create a new revenue line in your cash flow." + } + variant={revenueItem ? "update" : "create"} + /> + {!isEditing && ( +
+
+ {"Add Revenue Source"} +
+
+ )}
@@ -391,12 +438,34 @@ export default function CashAddEditRevenue({ : "bg-primary-900 text-white w-fit ml-auto text-sm mt-2" } /> -
+
+ )}
); } From dd85778d3a94738cbeb62641c2ec8ac62e52be23 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 01:07:07 -0400 Subject: [PATCH 63/68] Fixing errors --- .../cash-flow/components/CashAddEditRevenue.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx index 7d58ceb8..986c5409 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditRevenue.tsx @@ -109,10 +109,6 @@ export default function CashAddEditRevenue({ const [pendingRevenue, setPendingRevenue] = useState( null, ); - const [showConfirmModal, setShowConfirmModal] = useState(false); - const [pendingRevenue, setPendingRevenue] = useState( - null, - ); const showSuccessMessage = (message: string) => { setSuccessMessage(message); @@ -187,7 +183,6 @@ export default function CashAddEditRevenue({ setErrors({}); }; - const requestSubmit = () => { const requestSubmit = () => { setSuccessMessage(null); const payload = buildPayload(); @@ -198,13 +193,6 @@ export default function CashAddEditRevenue({ setShowConfirmModal(true); }; - const handleConfirmedSubmit = async () => { - if (!pendingRevenue) return; - const payload = pendingRevenue; - setPendingRevenue(payload); - setShowConfirmModal(true); - }; - const handleConfirmedSubmit = async () => { if (!pendingRevenue) return; const payload = pendingRevenue; From 9413b77bbdeafd433fb03857b4df346a149f362c Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 01:07:58 -0400 Subject: [PATCH 64/68] Fixing navbar --- frontend/src/main-page/navbar/NavBar.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx index 2f83df38..fb32191b 100644 --- a/frontend/src/main-page/navbar/NavBar.tsx +++ b/frontend/src/main-page/navbar/NavBar.tsx @@ -28,11 +28,7 @@ const NavBar: React.FC = observer(() => { const isAdmin = user?.position === UserStatus.Admin; const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false); - const handleLogout = async () => { - const { cashflowSettings } = getAppStore(); - if (cashflowSettings) { - await saveCashflowSettings(cashflowSettings); - } + const performLogout = async () => { logoutUser(); clearAllFilters(); navigate("/login"); From ca37771c03c6e0f4e252bc69d43f3a909b1a1dee Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Sat, 18 Apr 2026 01:09:28 -0400 Subject: [PATCH 65/68] Fixing edit cost errors --- .../cash-flow/components/CashAddEditCost.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx index c096275d..e1b68249 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddEditCost.tsx @@ -9,7 +9,6 @@ import { Frequency, frequencyIntervalsInMonths } from "../../../../../middle-lay import { TDateISO } from "../../../../../backend/src/utils/date"; import { getAppStore } from "../../../external/bcanSatchel/store"; import ActionConfirmation from "../../../components/ActionConfirmation"; -import ActionConfirmation from "../../../components/ActionConfirmation"; type FieldErrors = { type?: string; @@ -124,7 +123,6 @@ export default function CashAddEditCost({ setErrors({}); }; - const requestConfirm = () => { const requestConfirm = () => { setSuccessMessage(null); const payload = buildPayload(); @@ -135,13 +133,6 @@ export default function CashAddEditCost({ setShowConfirmModal(true); }; - const handleConfirmedSubmit = async () => { - if (!pendingCost) return; - const payload = pendingCost; - setPendingCost(payload); - setShowConfirmModal(true); - }; - const handleConfirmedSubmit = async () => { if (!pendingCost) return; const payload = pendingCost; @@ -150,7 +141,6 @@ export default function CashAddEditCost({ setErrors((previous) => ({ ...previous, submit: undefined })); const result = costItem - ? await saveCostEdits(payload, costItem.name) ? await saveCostEdits(payload, costItem.name) : await createNewCost(payload); if (!result.success) { @@ -341,7 +331,6 @@ export default function CashAddEditCost({ onClick={requestConfirm} disabled={isSubmitting} className="bg-green hover:!border-green text-white mt-2 text-sm lg:text-base active:!bg-green active:!border-green w-full" - className="bg-green hover:!border-green text-white mt-2 text-sm lg:text-base active:!bg-green active:!border-green w-full" /> ) : (
@@ -351,9 +340,6 @@ export default function CashAddEditCost({ className="bg-white text-black border border-grey-500 mt-2 text-sm lg:text-base" />