diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index d006096d..f177b5a8 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -396,7 +396,7 @@ describe('deleteGrantById', () => { promise: vi.fn().mockResolvedValue({}) }); - const result = await grantService.deleteGrantById('123'); + const result = await grantService.deleteGrantById(123); expect(mockDelete).toHaveBeenCalledTimes(1); //ensures delete() was called once @@ -404,7 +404,7 @@ describe('deleteGrantById', () => { expect(mockDelete).toHaveBeenCalledWith( expect.objectContaining({ TableName: expect.any(String), - Key: {grantId: '123'}, + Key: {grantId: 123}, ConditionExpression: 'attribute_exists(grantId)' }), ); @@ -421,7 +421,7 @@ describe('deleteGrantById', () => { promise: vi.fn().mockRejectedValue(conditionalError) }); - await expect(grantService.deleteGrantById('999')) + await expect(grantService.deleteGrantById(999)) .rejects.toThrow(/does not exist/); }); @@ -430,7 +430,7 @@ describe('deleteGrantById', () => { promise: vi.fn().mockRejectedValue(new Error('Some other DynamoDB error')) }); - await expect(grantService.deleteGrantById('123')) + await expect(grantService.deleteGrantById(123)) .rejects.toThrow(/Failed to delete/); }); }); diff --git a/backend/src/grant/grant.controller.ts b/backend/src/grant/grant.controller.ts index dff64af5..71f3e1d0 100644 --- a/backend/src/grant/grant.controller.ts +++ b/backend/src/grant/grant.controller.ts @@ -40,7 +40,7 @@ export class GrantController { } @Delete(':grantId') - async deleteGrant(@Param('grantId') grantId: string) { + async deleteGrant(@Param('grantId') grantId: number) { return await this.grantService.deleteGrantById(grantId); } @Get(':id') diff --git a/backend/src/grant/grant.module.ts b/backend/src/grant/grant.module.ts index cd670d73..82fa8101 100644 --- a/backend/src/grant/grant.module.ts +++ b/backend/src/grant/grant.module.ts @@ -5,6 +5,6 @@ import { NotificationsModule } from '../notifications/notification.module'; @Module({ imports: [NotificationsModule], controllers: [GrantController], - providers: [GrantService], + providers: [GrantService] }) export class GrantModule { } \ No newline at end of file diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 08ab72cb..cfbcde1d 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import AWS from 'aws-sdk'; +import AWS, { AWSError } from 'aws-sdk'; import { Grant } from '../../../middle-layer/types/Grant'; -import { NotificationService } from '.././notifications/notifcation.service'; +import { NotificationService } from '../notifications/notification.service'; import { Notification } from '../../../middle-layer/types/Notification'; import { TDateISO } from '../utils/date'; import { Status } from '../../../middle-layer/types/Status'; @@ -120,36 +120,39 @@ async makeGrantsInactive(grantId: number): Promise { * @param grantData */ async updateGrant(grantData: Grant): Promise { - // dynamically creates the update expression/attribute names based on names of grant interface - // assumption: grant interface field names are exactly the same as db storage naming - this.logger.warn('here' + grantData.status); - const updateKeys = Object.keys(grantData).filter( - key => key != 'grantId' - ); - const UpdateExpression = "SET " + updateKeys.map((key) => `#${key} = :${key}`).join(", "); - const ExpressionAttributeNames = updateKeys.reduce((acc, key) => - ({ ...acc, [`#${key}`]: key }), {}); - const ExpressionAttributeValues = updateKeys.reduce((acc, key) => - ({ ...acc, [`:${key}`]: grantData[key as keyof typeof grantData] }), {}); + + const updateKeys = Object.keys(grantData).filter( + key => key != 'grantId' + ); + + this.logger.warn('Update keys: ' + JSON.stringify(updateKeys)); + + const UpdateExpression = "SET " + updateKeys.map((key) => `#${key} = :${key}`).join(", "); + const ExpressionAttributeNames = updateKeys.reduce((acc, key) => + ({ ...acc, [`#${key}`]: key }), {}); + const ExpressionAttributeValues = updateKeys.reduce((acc, key) => + ({ ...acc, [`:${key}`]: grantData[key as keyof typeof grantData] }), {}); - const params = { - TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE", - Key: { grantId: grantData.grantId }, - UpdateExpression, - ExpressionAttributeNames, - ExpressionAttributeValues, - ReturnValues: "UPDATED_NEW", - }; + const params = { + TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE", + Key: { grantId: grantData.grantId }, + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues, + ReturnValues: "UPDATED_NEW", + }; - try { - const result = await this.dynamoDb.update(params).promise(); - await this.updateGrantNotifications(grantData); - return JSON.stringify(result); // returns the changed attributes stored in db - } catch(err) { - console.log(err); - throw new Error(`Failed to update Grant ${grantData.grantId}`) - } - } + try { + const result = await this.dynamoDb.update(params).promise(); + this.logger.warn('✅ Update successful!'); + //await this.updateGrantNotifications(grantData); + return JSON.stringify(result); + } catch(err: unknown) { + this.logger.error('=== DYNAMODB ERROR ==='); + this.logger.error('Unknown error type: ' + JSON.stringify(err)); + throw new Error(`Failed to update Grant ${grantData.grantId}`); + } + } // Add a new grant using the Grant interface from middleware. async addGrant(grant: Grant): Promise { @@ -193,10 +196,10 @@ async makeGrantsInactive(grantId: number): Promise { /* Deletes a grant from database based on its grant ID number * @param grantId */ - async deleteGrantById(grantId: string): Promise { + async deleteGrantById(grantId: number): Promise { const params = { TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE", - Key: { grantId: grantId }, + Key: { grantId: Number(grantId) }, ConditionExpression: "attribute_exists(grantId)", // ensures grant exists }; diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 2de27307..0b03a2b5 100644 --- a/backend/src/notifications/__test__/notification.service.test.ts +++ b/backend/src/notifications/__test__/notification.service.test.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Notification } from '../../../../middle-layer/types/Notification'; import { NotificationController } from '../notification.controller'; -import { NotificationService } from '../notifcation.service'; +import { NotificationService } from '../notification.service'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { servicesVersion } from 'typescript'; import { TDateISO } from '../../utils/date'; diff --git a/backend/src/notifications/notification.controller.ts b/backend/src/notifications/notification.controller.ts index bb1c0548..fd1d45cd 100644 --- a/backend/src/notifications/notification.controller.ts +++ b/backend/src/notifications/notification.controller.ts @@ -1,5 +1,5 @@ import { Controller, Post, Body, Get, Query, Param, Patch, Put, Delete } from '@nestjs/common'; -import { NotificationService } from './notifcation.service'; +import { NotificationService } from './notification.service'; import { Notification } from '../../../middle-layer/types/Notification'; diff --git a/backend/src/notifications/notification.module.ts b/backend/src/notifications/notification.module.ts index 6a4ff795..dcd2faa9 100644 --- a/backend/src/notifications/notification.module.ts +++ b/backend/src/notifications/notification.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { NotificationController } from './notification.controller'; -import { NotificationService } from './notifcation.service'; +import { NotificationService } from './notification.service'; @Module({ providers: [NotificationService], // providers perform business logic diff --git a/backend/src/notifications/notifcation.service.ts b/backend/src/notifications/notification.service.ts similarity index 100% rename from backend/src/notifications/notifcation.service.ts rename to backend/src/notifications/notification.service.ts diff --git a/frontend/src/custom/ActionConfirmation.tsx b/frontend/src/custom/ActionConfirmation.tsx new file mode 100644 index 00000000..b0e27154 --- /dev/null +++ b/frontend/src/custom/ActionConfirmation.tsx @@ -0,0 +1,126 @@ +import { IoIosWarning } from "react-icons/io"; + +{/* The popup that appears on delete + isOpen + - boolean if this should be visible or not + title + - The bold text at the very top + - Ex: "Delete Grant" + subtitle and boldSubtitle + - The text on the next line and the text that should be bolded + - Ex: "Are you sure you want to delete" and "{grantText}" + warningMessage + - The text in the orange warning box + - Ex: "By deleting this grant, they won't be available in the system anymore" + onCloseDelete function + - called when user clicks "No, cancel" + onConfirmDelete function + - called when user clicks "Yes, confirm" + + For an example, see GrantItem.tsx + + */} + const ActionConfirmation = ({ + isOpen, + onCloseDelete, + onConfirmDelete, + title, + subtitle = "Are you sure?", + boldSubtitle = "", + warningMessage = "This action cannot be undone." + }: { + isOpen: boolean; + onCloseDelete: () => void; + onConfirmDelete: () => void; + title: string; + subtitle: string; + boldSubtitle : string; + warningMessage: string; + }) => { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > + + {/* Title */} +

+ {title} +

+ + {/* Message */} +

+ {subtitle + " "} + {boldSubtitle} + {"?"} +

+ +
+ +
+ +
+
+
+ +

Warning

+
+

+ {warningMessage} +

+ +
+ +
+ + + {/* Buttons */} +
+ + +
+ +
+ +
+
+ ); + }; + + export default ActionConfirmation; \ No newline at end of file diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index cf240744..1cf8b230 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -8,15 +8,9 @@ import { useEffect, useState } from "react"; import { Grant } from "../../../../middle-layer/types/Grant.ts"; import FilterBar from "./filter-bar/FilterBar.tsx"; import { useAuthContext } from "../../context/auth/authContext"; -import { - updateEndDateFilter, - updateFilter, - updateStartDateFilter, - updateYearFilter, -} from "../../external/bcanSatchel/actions.ts"; -import { toJS } from "mobx"; -import { UserStatus } from "../../../../middle-layer/types/UserStatus.ts"; -import { Navigate } from "react-router-dom"; +import { updateEndDateFilter, updateFilter, updateStartDateFilter, updateYearFilter } from "../../external/bcanSatchel/actions.ts"; +import { fetchGrants } from "./filter-bar/processGrantData.ts"; + interface GrantPageProps { showOnlyMyGrants?: boolean; //if true, filters grants by user email @@ -71,11 +65,13 @@ function GrantPage({ showOnlyMyGrants = false }: GrantPageProps) { )}
- ) : - - ) : ( - +
+ {showNewGrantModal && ( + {setShowNewGrantModal(false); await fetchGrants();}} /> + )} +
+ ); -} +} export default GrantPage; diff --git a/frontend/src/main-page/grants/filter-bar/processGrantData.ts b/frontend/src/main-page/grants/filter-bar/processGrantData.ts index 2a444cff..6a8fd6e5 100644 --- a/frontend/src/main-page/grants/filter-bar/processGrantData.ts +++ b/frontend/src/main-page/grants/filter-bar/processGrantData.ts @@ -13,7 +13,7 @@ import { sortGrants } from "./grantSorter.ts"; import { api } from "../../../api.ts"; // fetch grants -const fetchGrants = async () => { +export const fetchGrants = async () => { try { const response = await api("/grant"); if (!response.ok) { diff --git a/frontend/src/main-page/grants/grant-list/GrantItem.tsx b/frontend/src/main-page/grants/grant-list/GrantItem.tsx index 42c1fb57..6dbc38a8 100644 --- a/frontend/src/main-page/grants/grant-list/GrantItem.tsx +++ b/frontend/src/main-page/grants/grant-list/GrantItem.tsx @@ -9,16 +9,16 @@ import { api } from "../../../api"; import { MdOutlinePerson2 } from "react-icons/md"; import Attachment from "../../../../../middle-layer/types/Attachment"; import NewGrantModal from "../new-grant/NewGrantModal"; +import ActionConfirmation from "../../../custom/ActionConfirmation"; +import { observer } from "mobx-react-lite"; +import { fetchGrants } from "../filter-bar/processGrantData"; interface GrantItemProps { grant: Grant; defaultExpanded?: boolean; } -const GrantItem: React.FC = ({ - grant, - defaultExpanded = false, -}) => { +const GrantItem: React.FC = observer(({ grant, defaultExpanded = false }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [isEditing, setIsEditing] = useState(false); const curGrant = grant; @@ -58,99 +58,50 @@ const GrantItem: React.FC = ({ setIsEditing(!isEditing); }; - { - /* The popup that appears on delete */ - } - const DeleteModal = ({ - isOpen, - onCloseDelete, - onConfirmDelete, - title = "Are you sure?", - message = "This action cannot be undone.", - }: { - isOpen: boolean; - onCloseDelete: () => void; - onConfirmDelete: () => void; - title?: string; - message?: string; - }) => { - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - {/* Icon */} -
-
- - - -
-
- - {/* Title */} -

- {title} -

- - {/* Message */} -

{message}

- - {/* Buttons */} -
- - -
-
-
- ); - }; + const deleteGrant = async () => { + setShowDeleteModal(false); + + console.log("=== DELETE GRANT DEBUG ==="); + console.log("Current grant:", curGrant); + console.log("Grant ID:", curGrant.grantId); + console.log("Organization:", curGrant.organization); + console.log("Full URL:", `/grant/${curGrant.grantId}`); + + try { + const response = await api(`/grant/${curGrant.grantId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + } + }); + + console.log("Response status:", response.status); + console.log("Response ok:", response.ok); + + if (response.ok) { + console.log("✅ Grant deleted successfully"); + // Refetch grants to update UI + await fetchGrants(); + } else { + // Get error details + const errorText = await response.text(); + console.error("❌ Error response:", errorText); + + let errorData; + try { + errorData = JSON.parse(errorText); + console.error("Parsed error:", errorData); + } catch { + console.error("Could not parse error response"); + } + } + } catch (err) { + console.error("=== EXCEPTION CAUGHT ==="); + console.error("Error type:", err instanceof Error ? "Error" : typeof err); + console.error("Error message:", err instanceof Error ? err.message : err); + console.error("Full error:", err); + } +}; function formatDate(isoString: string): string { const date = new Date(isoString); @@ -580,9 +531,37 @@ const GrantItem: React.FC = ({ className="text-lg flex block tracking-wide text-gray-700 font-bold mb-2" htmlFor="grid-city" > - Description - -
+ + setShowDeleteModal(false)} + onConfirmDelete={() => { + deleteGrant(); + }} + title="Delete Grant" + subtitle={"Are you sure you want to delete"} + boldSubtitle={curGrant.organization} + warningMessage="By deleting this grant, they won't be available in the system anymore." + /> + + +
+ + + +
- {showNewGrantModal && ( - setShowNewGrantModal(false)} - /> - )} -
+ {showNewGrantModal && ( + {setShowNewGrantModal(false); await fetchGrants();}} + /> + )} +
+ + ); -}; +}); export default GrantItem; diff --git a/frontend/src/main-page/grants/grant-list/index.tsx b/frontend/src/main-page/grants/grant-list/index.tsx index 0d8df84c..2d6f41cf 100644 --- a/frontend/src/main-page/grants/grant-list/index.tsx +++ b/frontend/src/main-page/grants/grant-list/index.tsx @@ -5,7 +5,7 @@ import GrantItem from "./GrantItem.tsx"; import GrantLabels from "./GrantLabels.tsx"; import { ButtonGroup, IconButton, Pagination } from "@chakra-ui/react"; import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; -import { ProcessGrantData } from "../filter-bar/processGrantData.ts"; +import { fetchGrants, ProcessGrantData } from "../filter-bar/processGrantData.ts"; import NewGrantModal from "../new-grant/NewGrantModal.tsx"; import { Grant } from "../../../../../middle-layer/types/Grant.ts"; @@ -67,23 +67,67 @@ const GrantList: React.FC = observer( const visibleItems = displayedGrants.slice(startRange, endRange); return ( -
-
- -
- {visibleItems.map((grant) => ( - - ))} - {visibleItems.length === 0 && ( -

- {showOnlyMyGrants - ? "You currently have no grants assigned as BCAN POC." - : "No grants found :("} -

+
+
+ +
+ {visibleItems.map((grant) => ( + + ))} + {visibleItems.length === 0 && ( +

+ {showOnlyMyGrants + ? "You currently have no grants assigned as BCAN POC." + : "No grants found>"} +

+ )} +
+
+ { + if (onClearSelectedGrant) { onClearSelectedGrant();}}} + onPageChange={(e) => { + setPage(e.page);}} + > + + + + + + + + {({ pages }) => + pages.map((page, index) => + page.type === "page" ? ( + setPage(page.value)} + aria-label={`Go to page ${page.value}`} + > + {page.value} + + ) : ( + "..." + ) + ) + } + + + + + + + + + {showNewGrantModal && ( + {setShowNewGrantModal(false); await fetchGrants(); }} /> )}
diff --git a/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx b/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx index b51759d6..b409c8ab 100644 --- a/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx +++ b/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx @@ -1,16 +1,15 @@ // frontend/src/grant-info/components/NewGrantModal.tsx import React, { useState, useEffect } from "react"; import CurrencyInput from 'react-currency-input-field'; -import { fetchAllGrants } from "../../../external/bcanSatchel/actions"; -import { getAppStore } from "../../../external/bcanSatchel/store"; import "../styles/NewGrantModal.css"; import { MdOutlinePerson2 } from "react-icons/md"; import { FiUpload } from "react-icons/fi"; import { Grant } from "../../../../../middle-layer/types/Grant"; import { TDateISO } from "../../../../../backend/src/utils/date"; import { Status } from "../../../../../middle-layer/types/Status"; -import { api } from "../../../api"; -import UserDropdown from "./UserDropdown"; +import { createNewGrant, saveGrantEdits } from "../new-grant/processGrantDataEditSave"; +import { observer } from "mobx-react-lite"; +import { fetchGrants } from "../filter-bar/processGrantData"; /** Attachment type from your middle layer */ enum AttachmentType { @@ -24,9 +23,9 @@ interface Attachment { url: string; type: AttachmentType; } +// const FilterBar: React.FC = observer(() => { - -const NewGrantModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { +const NewGrantModal: React.FC<{ grantToEdit : Grant | null , onClose: () => void }> = observer(({ grantToEdit, onClose }) => { /* grantId: number; organization: string; @@ -48,84 +47,73 @@ const NewGrantModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { // Form fields, renamed to match your screenshot // Used - const [organization, _setOrganization] = useState(""); + const [organization, _setOrganization] = useState(grantToEdit? grantToEdit.organization : ""); - // Used - const [applicationDate, _setApplicationDate] = useState(""); - // Used - const [grantStartDate, _setGrantStartDate] = useState(""); - // Used - const [reportDates, setReportDates] = useState([]); + // Helper function to normalize dates to YYYY-MM-DD format + const normalizeDateToISO = (date: TDateISO | ""): TDateISO | "" => { + if (!date) return ""; + // If it has time component, extract just the date part + return date.split('T')[0] as TDateISO; + }; // Used - const [timelineInYears, _setTimelineInYears] = useState(0); + const [applicationDate, _setApplicationDate] = useState( + grantToEdit?.application_deadline ? normalizeDateToISO(grantToEdit.application_deadline) : "" +); + +const [grantStartDate, _setGrantStartDate] = useState( + grantToEdit?.grant_start_date ? normalizeDateToISO(grantToEdit.grant_start_date) : "" +); + +const [reportDates, setReportDates] = useState<(TDateISO | "")[]>( + grantToEdit?.report_deadlines?.map(date => normalizeDateToISO(date)) || [] +); + + // Used + const [timelineInYears, _setTimelineInYears] = useState(grantToEdit? grantToEdit.timeline : 0); // Used - const [estimatedCompletionTimeInHours, _setEstimatedCompletionTimeInHours] = useState(0); + const [estimatedCompletionTimeInHours, _setEstimatedCompletionTimeInHours] = useState(grantToEdit? grantToEdit.estimated_completion_time : 10); // Used - const [doesBcanQualify, _setDoesBcanQualify] = useState(""); + const [doesBcanQualify, _setDoesBcanQualify] = useState( + grantToEdit ? (grantToEdit.does_bcan_qualify ? "yes" : "no") : "" +); // Used - const [isRestricted, _setIsRestricted] = useState(""); + const [isRestricted, _setIsRestricted] = useState(grantToEdit? String(grantToEdit.isRestricted) : ""); // Used - const [status, _setStatus] = useState(""); + const [status, _setStatus] = useState( + grantToEdit ? grantToEdit.status : "" +); // Used - const [amount, _setAmount] = useState(0); + const [amount, _setAmount] = useState(grantToEdit? grantToEdit.amount : 0); // Used - const [description, _setDescription] = useState(""); + const [description, _setDescription] = useState(grantToEdit? grantToEdit.description? grantToEdit.description : "" : ""); // Attachments array // Used - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState<(Attachment)[]>(grantToEdit?.attachments || []); // Used const [isAddingAttachment, setIsAddingAttachment] = useState(false); // Used - const [bcanPocName, setBcanPocName] = useState(''); - // Used - const [bcanPocEmail, setBcanPocEmail] = useState(''); + const [bcanPocName, setBcanPocName] = useState(grantToEdit? grantToEdit.bcan_poc? grantToEdit.bcan_poc.POC_name: '' : ''); + // Used? + const [bcanPocEmail, setBcanPocEmail] = useState(grantToEdit? grantToEdit.bcan_poc? grantToEdit.bcan_poc.POC_email : '' : ''); // Used - const [grantProviderPocName, setGrantProviderPocName] = useState(''); + const [grantProviderPocName, setGrantProviderPocName] = useState(grantToEdit? grantToEdit.grantmaker_poc? grantToEdit.grantmaker_poc.POC_name: '' : ''); // Used - const [grantProviderPocEmail, setGrantProviderPocEmail] = useState(''); + const [grantProviderPocEmail, setGrantProviderPocEmail] = useState(grantToEdit? grantToEdit.grantmaker_poc? grantToEdit.grantmaker_poc.POC_email: '' : ''); // For error handling - - const store = getAppStore(); - - useEffect(() => { - const fetchUsers = async () => { - console.log('Checking store for users...'); - console.log('Current activeUsers in store:', store.activeUsers); - console.log('Length:', store.activeUsers.length); - - if (store.activeUsers.length === 0) { - console.log('Fetching users...'); - try { - const response = await api("/user/active", { method: 'GET' }); - console.log('Response status:', response.status); - - if (response.ok) { - const users = await response.json(); - console.log('Fetched users:', users); - store.activeUsers = users; - } else { - console.log('Response not ok'); - } - } catch (error) { - console.error("Error fetching users:", error); - } - - } else { - console.log('Users already in store'); - } - }; - fetchUsers(); - }, []); + // @ts-ignore + const [_errorMessage, setErrorMessage] = useState(""); + const [showErrorPopup, setShowErrorPopup] = useState(false); + /* Add a new blank report date to the list */ // Used @@ -176,94 +164,207 @@ const NewGrantModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { /** Basic validations based on your screenshot fields */ const validateInputs = (): boolean => { - if (!organization) { - alert("Organization Name is required."); - return false; - } - // removed check for report dates -- they can be empty (potential grants would have no report dates) - if (!applicationDate || !grantStartDate) { - alert("Please fill out all date fields."); - return false; - } - if (amount <= 0) { - alert("Amount must be greater than 0."); - return false; + // Organization validation + if (!organization || organization.trim() === "") { + setErrorMessage("Organization Name is required."); + return false; + } + // Does BCAN Qualify validation + if (doesBcanQualify === "") { + setErrorMessage("Set Does BCAN Qualify? to 'yes' or 'no'"); + return false; + } + // Status validation + if (status === "" || status == null) { + setErrorMessage("Status is required."); + return false; + } + const validStatuses = [Status.Active, Status.Inactive, Status.Potential, Status.Pending, Status.Rejected]; + if (!validStatuses.includes(status as Status)) { + setErrorMessage("Invalid status selected."); + return false; + } + // Amount validation + if (amount <= 0) { + setErrorMessage("Amount must be greater than 0."); + return false; + } + if (isNaN(amount) || !isFinite(amount)) { + setErrorMessage("Amount must be a valid number."); + return false; + } + // Date validations + if (!applicationDate || applicationDate.trim() === "") { + setErrorMessage("Application Deadline is required."); + return false; + } + if (!grantStartDate || grantStartDate.trim() === "") { + setErrorMessage("Grant Start Date is required."); + return false; + } + + // const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/; + // if (!isoDateRegex.test(applicationDate)) { + // setErrorMessage("Application Deadline must be in valid date format (YYYY-MM-DD). instead of " + applicationDate); + // return false; + // } + // if (!isoDateRegex.test(grantStartDate)) { + // setErrorMessage("Grant Start Date must be in valid date format (YYYY-MM-DD)."); + // return false; + // } + // Validate dates are actual valid dates + const appDate = new Date(applicationDate); + const startDate = new Date(grantStartDate); + if (isNaN(appDate.getTime())) { + setErrorMessage("Application Deadline is not a valid date."); + return false; + } + if (isNaN(startDate.getTime())) { + setErrorMessage("Grant Start Date is not a valid date."); + return false; + } + // Logical date validation - grant start should typically be after application deadline + if (startDate < appDate) { + setErrorMessage("Grant Start Date should typically be after Application Deadline."); + return false; + } + + // Report deadlines validation + if (reportDates && reportDates.length > 0) { + for (let i = 0; i < reportDates.length; i++) { + const reportDate = reportDates[i]; + + // Skip empty entries (if you allow them) + if (!reportDate) { + setErrorMessage(`Report Date ${i + 1} cannot be empty. Remove it if not needed.`); + return false; + } + + const repDate = new Date(reportDate); + if (isNaN(repDate.getTime())) { + setErrorMessage(`Report Date ${i + 1} is not a valid date.`); + return false; + } + + // Report deadlines should be after grant start + if (repDate < startDate) { + setErrorMessage(`Report Date ${i + 1} should be after Grant Start Date.`); + return false; + } } - if (doesBcanQualify == "") { - alert("Set Does Bcan Qualify? to 'yes' or 'no' "); + } + // Timeline validation + if (timelineInYears < 0) { + setErrorMessage("Timeline cannot be negative."); + return false; + } + // Estimated completion time validation + if (estimatedCompletionTimeInHours < 0) { + setErrorMessage("Estimated Completion Time cannot be negative."); + return false; + } + if (estimatedCompletionTimeInHours === 0) { + setErrorMessage("Estimated Completion Time must be greater than 0."); + return false; + } + // Restriction type validation + if (isRestricted === "") { + setErrorMessage("Set Restriction Type to 'restricted' or 'unrestricted'"); + return false; + } + // BCAN POC validation + if (!bcanPocName || bcanPocName.trim() === "") { + setErrorMessage("BCAN Point of Contact Name is required."); + return false; + } + if (!bcanPocEmail || bcanPocEmail.trim() === "") { + setErrorMessage("BCAN Point of Contact Email is required."); + return false; + } + // Email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(bcanPocEmail)) { + setErrorMessage("BCAN Point of Contact Email must be a valid email address."); + return false; + } + // Grant Provider POC validation (optional, but if provided must be valid) + if (grantProviderPocName && grantProviderPocName.trim() !== "") { + if (grantProviderPocName.trim().length < 2) { + setErrorMessage("Grant Provider Point of Contact Name must be at least 2 characters."); return false; } - if (isRestricted == "") { - alert("Set Restriction Type to 'restricted' or 'unrestricted' "); + } + if (grantProviderPocEmail && grantProviderPocEmail.trim() !== "") { + if (!emailRegex.test(grantProviderPocEmail)) { + setErrorMessage("Grant Provider Point of Contact Email must be a valid email address."); return false; } - if (status == "") { - alert("Set Status"); - return false; + } + // Attachments validation + if (attachments && attachments.length > 0) { + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + if (!attachment.attachment_name || attachment.attachment_name.trim() === "") { + setErrorMessage(`Attachment ${i + 1} must have a name.`); + return false; + } + if (!attachment.url || attachment.url.trim() === "") { + setErrorMessage(`Attachment ${i + 1} must have a URL.`); + return false; + } + // Basic URL validation + try { + new URL(attachment.url); + } catch { + setErrorMessage(`Attachment ${i + 1} URL is not valid.`); + return false; + } } - return true; - }; - - /** On submit, POST the new grant, then re-fetch from the backend */ - const handleSubmit = async () => { - if (!validateInputs()) return; - - - // Convert attachments array - const attachmentsArray = attachments.map((att) => ({ - attachment_name: att.attachment_name.trim(), - url: att.url.trim(), - type: att.type, - })); + } + // Description validation (optional but reasonable length if provided) + if (description && description.length > 5000) { + setErrorMessage("Description is too long (max 5000 characters)."); + return false; + } + + return true; +}; + const handleSubmit = async () => { + if (!validateInputs()) { + setShowErrorPopup(true); + return; + } - /* Matches middle layer definition */ - const newGrant: Grant = { - grantId: -1, + const grantData: Grant = { + grantId: grantToEdit!.grantId, organization, - does_bcan_qualify: (doesBcanQualify == "yes" ? true : false), - amount : amount, + does_bcan_qualify: (doesBcanQualify === "yes"), + amount, grant_start_date: grantStartDate as TDateISO, application_deadline: applicationDate as TDateISO, - status: status as Status, // Potential = 0, Active = 1, Inactive = 2 + status: status as Status, bcan_poc: { POC_name: bcanPocName, POC_email: bcanPocEmail }, - grantmaker_poc: { POC_name: grantProviderPocName, POC_email: grantProviderPocEmail }, // Just take the first for now + grantmaker_poc: (grantProviderPocName && grantProviderPocEmail) ? { POC_name: grantProviderPocName, POC_email: grantProviderPocEmail } : { POC_name: '', POC_email: '' }, report_deadlines: reportDates as TDateISO[], timeline: timelineInYears, estimated_completion_time: estimatedCompletionTimeInHours, - description: description, - attachments: attachmentsArray, - isRestricted: (isRestricted == "restricted" ? true : false), // Default to unrestricted for now + description : description? description : "", + attachments: attachments, + isRestricted: (isRestricted === "restricted"), }; - console.log(newGrant); - try { - const response = await api("/grant/new-grant", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newGrant), - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error("Failed to add grant:", errorData.message); - alert("Failed to add grant") - return; - } - // Re-fetch the full list of grants - const grantsResponse = await api("/grant"); - if (!grantsResponse.ok) { - throw new Error("Failed to re-fetch grants."); - } - const updatedGrants = await grantsResponse.json(); - // Update the store - fetchAllGrants(updatedGrants); + const result = grantToEdit + ? await saveGrantEdits(grantData) + : await createNewGrant(grantData); + if (result.success) { onClose(); - } catch (error) { -alert("Server error please try again."); - console.error(error); + await fetchGrants(); // ← Call it here instead + } else { + setErrorMessage(result.error || "An error occurred"); + setShowErrorPopup(true); } }; @@ -284,7 +385,8 @@ alert("Server error please try again."); className=" font-family-helvetica block w-full text-black placeholder:text-gray-400 border rounded py-3 px-4 mb-3 leading-tight" id="grid-first-name" type="text" - placeholder="Type Here" + placeholder = "Type Here" + value = {organization} onChange={(e) => _setOrganization(e.target.value)}/>
@@ -305,8 +407,8 @@ alert("Server error please try again."); className="font-family-helvetica appearance-none block w-full border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="grid-city" type="date" - - onChange={(e) => _setApplicationDate(e.target.value)}/> + value={applicationDate ? applicationDate.split('T')[0] : ""} + onChange={(e) => _setApplicationDate(e.target.value as TDateISO)}/>
{/*Grant Start Date and input */}
@@ -317,7 +419,8 @@ alert("Server error please try again."); className="font-family-helvetica w-full appearance-none block w-full bg-gray-200 text-black placeholder:text-gray-400 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="grid-city" type="date" - onChange={(e) => _setGrantStartDate(e.target.value)}/> + value={grantStartDate ? grantStartDate.split('T')[0] : ""} + onChange={(e) => _setGrantStartDate(e.target.value as TDateISO)}/>
@@ -331,6 +434,7 @@ alert("Server error please try again."); style={{height: "48px", backgroundColor: '#F2EBE4', borderStyle: 'solid', borderColor: 'black', borderWidth: '1px'}} className="font-family-helvetica appearance-none block w-full bg-gray-200 text-black placeholder:text-gray-400 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="grid-city" + value = {estimatedCompletionTimeInHours} onChange={(e) => _setEstimatedCompletionTimeInHours(Number(e.target.value))}/> @@ -351,10 +455,10 @@ alert("Server error please try again."); style={{height: "42px", backgroundColor: '#F2EBE4', borderStyle: 'solid', borderColor: 'black', borderWidth: '1px'}} className="font-family-helvetica flex-1 min-w-0 text-black rounded" type="date" - value={date} + value={date ? (date.includes('T') ? date.split('T')[0] : date) : ""} onChange={(e) => { const newDates = [...reportDates]; - newDates[index] = e.target.value; + newDates[index] = e.target.value as TDateISO | ""; setReportDates(newDates); }} /> @@ -390,7 +494,7 @@ alert("Server error please try again."); _setTimelineInYears(Number(e.target.value))}/> + type="number" min = "0" placeholder="Type Here" value = {timelineInYears} onChange={(e) => _setTimelineInYears(Number(e.target.value))}/> {/*Amount label and input */} @@ -400,7 +504,7 @@ alert("Server error please try again."); _setAmount(Number(value))}/> + min={0} decimalsLimit={2} placeholder="Type Here" value = {amount} onValueChange={(value) => _setAmount(Number(value))}/> @@ -419,20 +523,12 @@ alert("Server error please try again.");
- { - setBcanPocName(user.name); - setBcanPocEmail(user.email) - }} - placeholder="Name" - /> + setBcanPocName(e.target.value)}/> + id="grid-city" placeholder="e-mail" value = {bcanPocEmail} onChange={(e) => setBcanPocEmail(e.target.value)}/>
@@ -447,9 +543,11 @@ alert("Server error please try again.");
setGrantProviderPocName(e.target.value)}/> + className="font-family-helvetica w-full text-gray-700 rounded" id="grid-city" placeholder="Name" + value = {grantProviderPocName} onChange={(e) => setGrantProviderPocName(e.target.value)}/> setGrantProviderPocEmail(e.target.value)}/> + className="font-family-helvetica w-full text-gray-700 rounded" id="grid-city" placeholder="e-mail" + value = {grantProviderPocEmail} onChange={(e) => setGrantProviderPocEmail(e.target.value)}/>
@@ -478,15 +576,15 @@ alert("Server error please try again."); - @@ -675,11 +773,28 @@ alert("Server error please try again."); {/*End modal content */} + {/* Error Popup */} + {showErrorPopup && ( +
+
+

Error

+

{_errorMessage}

+ +
+
+ )} + {/*End modal overlay */} ); -}; +}); export default NewGrantModal; \ No newline at end of file diff --git a/frontend/src/main-page/grants/new-grant/processGrantDataEditSave.ts b/frontend/src/main-page/grants/new-grant/processGrantDataEditSave.ts new file mode 100644 index 00000000..9b6c52c2 --- /dev/null +++ b/frontend/src/main-page/grants/new-grant/processGrantDataEditSave.ts @@ -0,0 +1,74 @@ +import { Grant } from "../../../../../middle-layer/types/Grant"; +import { api } from "../../../api.ts"; +import { fetchGrants } from "../filter-bar/processGrantData.ts"; + +// save a new grant +export const createNewGrant = async (newGrant: Grant) => { + try { + const response = await api("/grant/new-grant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newGrant), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.errMessage || "Failed to add grant."); + } + await fetchGrants(); + return { success: true }; + } catch (error) { + console.error("Error creating grant:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Server error. Please try again." + }; + } + +}; + +// update an existing grant +export const saveGrantEdits = async (updatedGrant: Grant) => { + try { + console.log("=== SAVE GRANT EDITS DEBUG ==="); + console.log("Grant being sent:", updatedGrant); + console.log("Grant ID:", updatedGrant.grantId); + console.log("Stringified body:", JSON.stringify(updatedGrant)); + + + + const response = await api("/grant/save", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatedGrant), + }); + + console.log("Response status:", response.status); + console.log("Response ok:", response.ok); + + if (!response.ok) { + // Try to get error details + const errorText = await response.text(); + console.error("Error response body:", errorText); + + let errorData; + try { + errorData = JSON.parse(errorText); + } catch { + throw new Error(`Server error (${response.status}): ${errorText}`); + } + + throw new Error(errorData.errMessage || `Failed to update grant (${response.status})`); + } + await fetchGrants(); + return { success: true }; + } catch (error) { + console.error("=== ERROR UPDATING GRANT ==="); + console.error("Error details:", error); + console.error("Error type:", error instanceof Error ? "Error" : typeof error); + return { + success: false, + error: error instanceof Error ? error.message : "Server error. Please try again." + }; + } +}; \ No newline at end of file