diff --git a/backend/src/grant/types/grant.types.ts b/backend/src/grant/types/grant.types.ts index 2d090a29..a50cae4f 100644 --- a/backend/src/grant/types/grant.types.ts +++ b/backend/src/grant/types/grant.types.ts @@ -22,6 +22,9 @@ export class GrantResponseDto { @ApiProperty({ description: 'When grant submission is due', example: '2024-06-01T00:00:00.000Z' }) application_deadline!: string; + + @ApiProperty({ description: 'When grant was submitted', example: '2024-06-01T00:00:00.000Z' }) + application_date?: string; @ApiProperty({ description: 'Multiple report dates', type: [String], required: false }) report_deadlines?: string[]; @@ -75,6 +78,9 @@ export class AddGrantBody { @ApiProperty({ description: 'When grant submission is due', example: '2024-06-01T00:00:00.000Z' }) application_deadline!: string; + + @ApiProperty({ description: 'When grant was submitted', example: '2024-06-01T00:00:00.000Z' }) + application_date?: string; @ApiProperty({ description: 'Multiple report dates', type: [String], required: false, example: ['2024-12-01T00:00:00.000Z'] }) report_deadlines?: string[]; @@ -122,6 +128,9 @@ export class UpdateGrantBody { @ApiProperty({ description: 'When grant submission is due', required: false }) application_deadline?: string; + + @ApiProperty({ description: 'When grant was submitted', required: false }) + application_date?: string; @ApiProperty({ description: 'Multiple report dates', type: [String], required: false }) report_deadlines?: string[]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3fada70..c34185eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -45,7 +45,6 @@ "@types/react-transition-group": "^4.4.12", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", - "baseline-browser-mapping": "^2.8.29", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", @@ -6226,12 +6225,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", - "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -6515,9 +6517,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001743", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", - "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "version": "1.0.30001779", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", "funding": [ { "type": "opencollective", diff --git a/frontend/package.json b/frontend/package.json index c0e5558b..4f28f545 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,6 @@ "@types/react-transition-group": "^4.4.12", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", - "baseline-browser-mapping": "^2.8.29", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", diff --git a/frontend/src/Footer.tsx b/frontend/src/Footer.tsx index ad2aa5d9..34927a24 100644 --- a/frontend/src/Footer.tsx +++ b/frontend/src/Footer.tsx @@ -4,7 +4,7 @@ import { FooterText } from "./translations/general"; const Footer: React.FC = () => { return ( -
+
{FooterText.C4C_Motto} diff --git a/frontend/src/custom/ActionConfirmation.tsx b/frontend/src/components/ActionConfirmation.tsx similarity index 91% rename from frontend/src/custom/ActionConfirmation.tsx rename to frontend/src/components/ActionConfirmation.tsx index 34dafc21..fd066ea8 100644 --- a/frontend/src/custom/ActionConfirmation.tsx +++ b/frontend/src/components/ActionConfirmation.tsx @@ -27,7 +27,7 @@ import { IoIosWarning } from "react-icons/io"; onClick={onCloseDelete} >
e.stopPropagation()} > @@ -53,7 +53,7 @@ import { IoIosWarning } from "react-icons/io";

Warning

-

+

{warningMessage}

diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index f6c3e0fa..60f4c8ee 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -2,39 +2,58 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; type ButtonProps = { - text: string; - onClick: () => void; - className?: string; - logo?: IconProp; - logoPosition?: 'left' | 'right'; - disabled?: boolean; - type?: "button" | "submit" | "reset"; -} - + text: string; + onClick: () => void; + className?: string; + logo?: IconProp; + logoPosition?: "left" | "right" | "center"; + disabled?: boolean; + type?: "button" | "submit" | "reset"; +}; // Button component where you can pass in text, onClick handler, optional className // for styling, and an optional logo with its position. //Styling is default, but can be overridden by passing in a className prop -export default function Button({ text, onClick, className, logo, logoPosition, disabled, type }: ButtonProps) { +export default function Button({ + text, + onClick, + className, + logo, + logoPosition, + disabled, + type, +}: ButtonProps) { return ( - ); -} \ No newline at end of file +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index debac718..5e7eb79e 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -27,7 +27,7 @@ export default function SearchBar({ /> { const store = getAppStore(); store.activeUsers = actionMessage.users; persistToSessionStorage(); }); -/** +/** * setInactiveUsers mutator -*/ + */ mutator(setInactiveUsers, (actionMessage) => { const store = getAppStore(); store.inactiveUsers = actionMessage.users; @@ -42,13 +43,13 @@ mutator(setInactiveUsers, (actionMessage) => { * setAuthState mutator */ mutator(setAuthState, (actionMessage) => { - console.log('=== setAuthState MUTATOR CALLED ==='); + console.log("=== setAuthState MUTATOR CALLED ==="); const store = getAppStore(); - console.log('Setting user:', actionMessage.user); + console.log("Setting user:", actionMessage.user); store.isAuthenticated = actionMessage.isAuthenticated; store.user = actionMessage.user; store.accessToken = actionMessage.accessToken; - console.log('Calling persistToSessionStorage...'); + console.log("Calling persistToSessionStorage..."); persistToSessionStorage(); }); @@ -99,9 +100,23 @@ mutator(logoutUser, () => { store.isAuthenticated = false; store.user = null; store.accessToken = null; - sessionStorage.removeItem('bcanAppStore'); + sessionStorage.removeItem("bcanAppStore"); }); +// Clears all store filters +mutator(clearAllFilters, () => { + const store = getAppStore(); + store.filterStatus = null; + store.startDateFilter = null; + store.endDateFilter = null; + store.searchQuery = ""; + store.yearFilter = []; + store.userQuery = ""; + store.emailFilter = false; + store.eligibleOnly = false; + store.amountMinFilter = null; + store.amountMaxFilter = null; +}); /** * Reassigns all grants to new grants from the backend. @@ -117,16 +132,32 @@ mutator(fetchAllGrants, (actionMessage) => { mutator(updateFilter, (actionMessage) => { const store = getAppStore(); store.filterStatus = actionMessage.status; -}) +}); mutator(updateStartDateFilter, (actionMessage) => { const store = getAppStore(); store.startDateFilter = actionMessage.startDateFilter; -}) +}); mutator(updateEndDateFilter, (actionMessage) => { const store = getAppStore(); store.endDateFilter = actionMessage.endDateFilter; +}); + +mutator(updateUserEmailFilter, (actionMessage) => { + const store = getAppStore(); + store.emailFilter = actionMessage.userEmailFilter; +}) + +mutator(updateEligibleOnly, (actionMessage) => { + const store = getAppStore(); + store.eligibleOnly = actionMessage.eligibleOnly; +}) + +mutator(updateAmountRange, (actionMessage) => { + const store = getAppStore(); + store.amountMinFilter = actionMessage.amountMinFilter; + store.amountMaxFilter = actionMessage.amountMaxFilter; }) mutator(updateUserEmailFilter, (actionMessage) => { @@ -148,33 +179,32 @@ mutator(updateAmountRange, (actionMessage) => { mutator(updateSearchQuery, (actionMessage) => { const store = getAppStore(); store.searchQuery = actionMessage.searchQuery; -}) +}); mutator(updateYearFilter, (actionMessage) => { const store = getAppStore(); store.yearFilter = actionMessage.yearFilter; -}) +}); mutator(setNotifications, (actionMessage) => { const store = getAppStore(); store.notifications = actionMessage.notifications; -}) +}); mutator(updateSort, (actionMessage) => { const store = getAppStore(); store.sort = actionMessage.sort; -}) +}); mutator(updateUserQuery, (actionMessage) => { const store = getAppStore(); store.userQuery = actionMessage.userQuery; - console.log('Updated userQuery:', store.userQuery); -}) +}); mutator(updateUserSort, (actionMessage) => { const store = getAppStore(); store.userSort = actionMessage.sort; -}) +}); mutator(removeProfilePic, () => { const store = getAppStore(); @@ -192,4 +222,4 @@ mutator(removeProfilePic, () => { } persistToSessionStorage(); -}); \ No newline at end of file +}); diff --git a/frontend/src/main-page/MainPage.tsx b/frontend/src/main-page/MainPage.tsx index 07f91eba..8149cb35 100644 --- a/frontend/src/main-page/MainPage.tsx +++ b/frontend/src/main-page/MainPage.tsx @@ -1,4 +1,4 @@ -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, useLocation } from "react-router-dom"; import Dashboard from "./dashboard/Dashboard"; import GrantPage from "./grants/GrantPage"; import NavBar from "./navbar/NavBar"; @@ -12,8 +12,9 @@ import { UserStatus } from "../../../middle-layer/types/UserStatus"; import { observer } from "mobx-react-lite"; import { Navigate } from "react-router-dom"; import { getAppStore } from "../external/bcanSatchel/store"; -import BellButton from "./navbar/Bell"; -import { useState } from "react"; +import BellButton from "./notifications/Bell"; +import { useEffect, useState } from "react"; +import { clearAllFilters } from "../external/bcanSatchel/actions"; interface PositionGuardProps { children: React.ReactNode; @@ -48,13 +49,20 @@ const PositionGuard = observer( function MainPage() { const [openModal, setOpenModal] = useState(false); + const location = useLocation(); + + // Clears all store filters when page changes + useEffect(() => { + clearAllFilters(); + }, [location]); + return (
-
-
+
+
@@ -64,6 +72,7 @@ function MainPage() { element={ +
} /> @@ -80,6 +89,7 @@ function MainPage() { element={ +
} /> @@ -89,6 +99,7 @@ function MainPage() { element={ +
} /> @@ -97,6 +108,7 @@ function MainPage() { element={ +
} /> @@ -110,7 +122,6 @@ function MainPage() { />
-
); diff --git a/frontend/src/main-page/dashboard/Dashboard.tsx b/frontend/src/main-page/dashboard/Dashboard.tsx index dc490c83..30013291 100644 --- a/frontend/src/main-page/dashboard/Dashboard.tsx +++ b/frontend/src/main-page/dashboard/Dashboard.tsx @@ -1,34 +1,18 @@ -import CsvExportButton from "./CsvExportButton"; +import CsvExportButton from "./components/CsvExportButton"; -import DateFilter from "./DateFilter"; +import DateFilter from "./components/DateFilter"; import "./styles/Dashboard.css"; import { observer } from "mobx-react-lite"; -import StackedBarMoneyReceived from "./Charts/StackedBarMoneyReceived"; -import { useEffect } from "react"; -import { - updateYearFilter, - updateFilter, - updateEndDateFilter, - updateStartDateFilter, - updateSearchQuery, -} from "../../external/bcanSatchel/actions"; +import StackedBarMoneyReceived from "./components/StackedBarMoneyReceived"; import { getAppStore } from "../../external/bcanSatchel/store"; -import BarYearGrantStatus from "./Charts/BarYearGrantStatus"; -import LineChartSuccessRate from "./Charts/LineChartSuccessRate"; -import GanttYearGrantTimeline from "./Charts/GanttYearGrantTimeline"; -import DonutMoneyApplied from "./Charts/DonutMoneyApplied"; +import BarYearGrantStatus from "./components/BarYearGrantStatus"; +import LineChartSuccessRate from "./components/LineChartSuccessRate"; +import GanttYearGrantTimeline from "./components/GanttYearGrantTimeline"; +import DonutMoneyApplied from "./components/DonutMoneyApplied"; import { ProcessGrantData } from "../grants/filter-bar/processGrantData"; -import KPICards from "./Charts/KPICards"; +import KPICards from "./components/KPICards"; const Dashboard = observer(() => { - // reset filters on initial render - useEffect(() => { - updateYearFilter([]); - updateFilter(null); - updateEndDateFilter(null); - updateStartDateFilter(null); - updateSearchQuery(""); - }, []); const { yearFilter, allGrants } = getAppStore(); diff --git a/frontend/src/main-page/dashboard/Charts/BarYearGrantStatus.tsx b/frontend/src/main-page/dashboard/components/BarYearGrantStatus.tsx similarity index 100% rename from frontend/src/main-page/dashboard/Charts/BarYearGrantStatus.tsx rename to frontend/src/main-page/dashboard/components/BarYearGrantStatus.tsx diff --git a/frontend/src/main-page/dashboard/CsvExportButton.tsx b/frontend/src/main-page/dashboard/components/CsvExportButton.tsx similarity index 88% rename from frontend/src/main-page/dashboard/CsvExportButton.tsx rename to frontend/src/main-page/dashboard/components/CsvExportButton.tsx index 5df10059..2594fe51 100644 --- a/frontend/src/main-page/dashboard/CsvExportButton.tsx +++ b/frontend/src/main-page/dashboard/components/CsvExportButton.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; -import { downloadCsv, CsvColumn } from "../../utils/csvUtils"; -import { Grant } from "../../../../middle-layer/types/Grant"; -import { ProcessGrantData } from "../../main-page/grants/filter-bar/processGrantData"; +import { downloadCsv, CsvColumn } from "../../../utils/csvUtils"; +import { Grant } from "../../../../../middle-layer/types/Grant"; +import { ProcessGrantData } from "../../grants/filter-bar/processGrantData"; import { observer } from "mobx-react-lite"; -import { getAppStore } from "../../external/bcanSatchel/store"; +import { getAppStore } from "../../../external/bcanSatchel/store"; import { faDownload } from "@fortawesome/free-solid-svg-icons"; -import Attachment from "../../../../middle-layer/types/Attachment"; -import POC from "../../../../middle-layer/types/POC"; -import Button from "../../components/Button"; +import Attachment from "../../../../../middle-layer/types/Attachment"; +import POC from "../../../../../middle-layer/types/POC"; +import Button from "../../../components/Button"; // Define the columns for the CSV export, including any necessary formatting. const columns: CsvColumn[] = [ { key: "grantId", title: "Grant ID" }, diff --git a/frontend/src/main-page/dashboard/DateFilter.tsx b/frontend/src/main-page/dashboard/components/DateFilter.tsx similarity index 90% rename from frontend/src/main-page/dashboard/DateFilter.tsx rename to frontend/src/main-page/dashboard/components/DateFilter.tsx index 3317ce07..1ad4ea5b 100644 --- a/frontend/src/main-page/dashboard/DateFilter.tsx +++ b/frontend/src/main-page/dashboard/components/DateFilter.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from "react"; -import { updateYearFilter } from "../../external/bcanSatchel/actions"; -import { getAppStore } from "../../external/bcanSatchel/store"; +import { updateYearFilter } from "../../../external/bcanSatchel/actions"; +import { getAppStore } from "../../../external/bcanSatchel/store"; import { observer } from "mobx-react-lite"; import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import Button from "../../components/Button"; +import Button from "../../../components/Button"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const DateFilter: React.FC = observer(() => { @@ -52,7 +52,7 @@ const DateFilter: React.FC = observer(() => { }; return ( -
+
+ + {curGrant ? (
+
+ {grants.map((grant) => ( + setCurId(grant.grantId)} /> - )} + ))} +
+
+
+
) : (
+ No grants found. +
)} +
+ {showEditGrant && ( + { + setShowEditGrant(false); + }} + /> + )}
- ) : ( - - ) - ) : ( - +
); } diff --git a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx new file mode 100644 index 00000000..d9656b91 --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx @@ -0,0 +1,220 @@ +import { useReducer, useState } from "react"; +import Button from "../../../components/Button.tsx"; +import { Grant } from "../../../../../middle-layer/types/Grant.ts"; +import { observer } from "mobx-react-lite"; +import { TDateISO } from "../../../../../backend/src/utils/date.ts"; +import { Status } from "../../../../../middle-layer/types/Status.ts"; +import Attachment from "../../../../../middle-layer/types/Attachment.ts"; +import { + createNewGrant, + reducer, + saveGrantEdits, + deleteGrant +} from "./processGrantDataEditSave.ts"; +import EditGrantContacts from "./components/EditGrantContacts.tsx"; +import ErrorPopup from "./components/ErrorPopup.tsx"; +import EditGrantInfo from "./components/EditGrantInfo.tsx"; +import EditGrantHeader from "./components/EditGrantHeader.tsx"; +import { EditGrantDocuments } from "./components/EditGrantDocuments.tsx"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import ActionConfirmation from "../../../components/ActionConfirmation.tsx"; + +export interface GrantFormState { + organization: string; + applicationDate: TDateISO | ""; + applicationDeadline: TDateISO | ""; + grantStartDate: TDateISO | ""; + reportDates: (TDateISO | "")[]; + timeline: number | null; + estimatedCompletionTime: number | null; + doesBcanQualify: "yes" | "no" | ""; + isRestricted: "restricted" | "unrestricted" | ""; + status: Status; + amount: number | null; + description: string; + attachments: Attachment[]; + bcanPocName: string; + bcanPocEmail: string; + grantProviderPocName: string; + grantProviderPocEmail: string; +} + +const EditGrant: React.FC<{ + grantToEdit: Grant | null; + onClose: () => void; +}> = observer(({ grantToEdit, onClose }) => { + // State to track if form was submitted successfully + const [saving, setSaving] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const [form, dispatch] = useReducer(reducer, { + organization: grantToEdit?.organization ?? "", + applicationDate: grantToEdit?.application_date ?? "", + applicationDeadline: grantToEdit?.application_deadline ?? "", + grantStartDate: grantToEdit?.grant_start_date ?? "", + reportDates: grantToEdit?.report_deadlines ?? [], + timeline: grantToEdit?.timeline ?? null, + estimatedCompletionTime: grantToEdit?.estimated_completion_time ?? null, + doesBcanQualify: grantToEdit + ? grantToEdit.does_bcan_qualify + ? "yes" + : "no" + : "", + isRestricted: grantToEdit + ? grantToEdit.isRestricted + ? "restricted" + : "unrestricted" + : "", + status: grantToEdit?.status ?? Status.Inactive, + amount: grantToEdit?.amount ?? null, + description: grantToEdit?.description ?? "", + attachments: grantToEdit?.attachments ?? [], + bcanPocName: grantToEdit?.bcan_poc?.POC_name ?? "", + bcanPocEmail: grantToEdit?.bcan_poc?.POC_email ?? "", + grantProviderPocName: grantToEdit?.grantmaker_poc?.POC_name ?? "", + grantProviderPocEmail: grantToEdit?.grantmaker_poc?.POC_email ?? "", + }); + + const validateInputs = (): string | null => { + if (!form.organization.trim()) return "Organization Name is required"; + if (!form.status) return "Status is required"; + if (form.amount == null || form.amount <= 0) return "Amount must be greater than 0"; + if (!form.applicationDeadline) return "Due Date is required"; + if (!form.grantStartDate) return "Grant Start Date is required"; + if (!form.estimatedCompletionTime || form.estimatedCompletionTime <= 0) return "Estimated completion time must be greater than 0"; + if (!form.doesBcanQualify) return "BCAN eligibility is required"; + if(form.reportDates.length > 0 && !form.reportDates.every((date) => date !== "")) return "Report deadlines must have a value"; + if (!form.timeline || form.timeline <= 0) return "Timeline must be greater than 0"; + if (!form.bcanPocEmail) return "BCAN contact required"; + if (!form.grantProviderPocEmail) return "Grant provider contact required"; + if(form.attachments.length > 0 && !form.attachments.every((attachment) => attachment.url !== "")) return "Attachments must have a value"; + return null; + }; + + const buildGrant = (): Grant => ({ + grantId: grantToEdit?.grantId ?? 0, + organization: form.organization, + does_bcan_qualify: form.doesBcanQualify === "yes", + amount: form.amount ?? 0, + grant_start_date: form.grantStartDate as TDateISO, + application_deadline: form.applicationDeadline as TDateISO, + application_date: form.applicationDate as TDateISO, + status: form.status as Status, + report_deadlines: form.reportDates as TDateISO[], + timeline: form.timeline ?? 0, + estimated_completion_time: form.estimatedCompletionTime ?? 0, + description: form.description, + attachments: form.attachments, + isRestricted: form.isRestricted === "restricted", + bcan_poc: { + POC_name: form.bcanPocName, + POC_email: form.bcanPocEmail, + }, + grantmaker_poc: { + POC_name: form.grantProviderPocName, + POC_email: form.grantProviderPocEmail, + }, + }); + + const [_errorMessage, setErrorMessage] = useState(""); + const [showErrorPopup, setShowErrorPopup] = useState(false); + + const handleDelete = async () => { + setShowDeleteModal(false); + deleteGrant(grantToEdit?.grantId) + onClose(); + } + + const handleSubmit = async () => { + setSaving(true); + const error = validateInputs(); + + if (error) { + setErrorMessage(error); + setShowErrorPopup(true); + setSaving(false); + return; + } + + const grantData = buildGrant(); + + const result = grantToEdit + ? await saveGrantEdits(grantData) + : await createNewGrant(grantData); + + if (result.success) { + setSaving(false); + onClose(); + } else { + setErrorMessage(result.error ?? "An error occurred"); + setShowErrorPopup(true); + } + }; + + return ( +
+
+
+ {/* Header with Buttons */} +
+ +
+
+
+ + {/* Divider */} +
+ + {/* Description */} + + + {/* Divider */} +
+ + {/* Contacts and Documents Section */} + {/* Contacts */} + + {/* Documents */} + + + {/* Divider */} + {grantToEdit && (
+
+
)} +
+
+ {/* Error Popup */} + {showErrorPopup && ( + setShowErrorPopup(false)} + /> + )} +
+ ); +}); + +export default EditGrant; diff --git a/frontend/src/main-page/grants/edit-grant/components/AddAttachmentPopup.tsx b/frontend/src/main-page/grants/edit-grant/components/AddAttachmentPopup.tsx new file mode 100644 index 00000000..7eac171f --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/AddAttachmentPopup.tsx @@ -0,0 +1,88 @@ +import { InputField } from "../../../../sign-up"; +import Button from "../../../../components/Button"; +import { Action } from "../processGrantDataEditSave"; +import { useState } from "react"; +import { observer } from "mobx-react-lite"; + +type AddPopupProps = { + setShowPopup: () => void; + dispatch: React.Dispatch; +}; + +const AddAttachmentPopup = observer( + ({ setShowPopup, dispatch }: AddPopupProps) => { + const [attachment, setAttachment] = useState(null); + + const handleAdd = () => { + dispatch({ + type: "ADD_ATTACHMENT", + attachment: { ...attachment }, + }); + + setShowPopup(); + }; + + return ( +
+
+
Add Attachment
+ +
+ { + setAttachment({ + ...attachment, + attachment_name: e.target.value, + }); + }} + /> + + { + let value = e.target.value.trim(); + + if (value && !/^https?:\/\//i.test(value)) { + value = `https://${value}`; + } + + setAttachment({ + ...attachment, + url: value, + }); + }} + /> +
+ + {/* ACTION BUTTONS */} +
+
+
+
+ ); + }, +); + +export default AddAttachmentPopup; diff --git a/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx b/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx new file mode 100644 index 00000000..b74830ba --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/AddContactPopup.tsx @@ -0,0 +1,237 @@ +import { InputField } from "../../../../sign-up"; +import Button from "../../../../components/Button"; +import { Action } from "../processGrantDataEditSave"; +import { GrantFormState } from "../EditGrant"; +import { useState } from "react"; +import UserSearch from "../../../users/components/UserSearch"; +import { ProcessUserData } from "../../../../main-page/users/processUserData"; +import { observer } from "mobx-react-lite"; +import logo from "../../../../images/logo.svg"; + +type AddPopupProps = { + setShowPopup: () => void; + dispatch: React.Dispatch; + form: GrantFormState; +}; + +type ContactType = "BCAN" | "Granter"; + +const AddContactPopup = observer( + ({ setShowPopup, dispatch, form }: AddPopupProps) => { + const [type, setType] = useState( + form.bcanPocEmail ? "Granter" : "BCAN", + ); + const [selectedUser, setSelectedUser] = useState(null); + const [error, setError] = useState(null); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + const { activeUsers } = ProcessUserData(); + + const validateUser = () => { + if (!selectedUser) { + setError("No user selected"); + return false; + } + + if (!selectedUser.firstName) { + setError("firstName"); + return false; + } + + if (!selectedUser.lastName) { + setError("lastName"); + return false; + } + + if (!emailRegex.test(selectedUser.email)) { + setError("email"); + return false; + } + + setError(null); + return true; + }; + + const handleAdd = () => { + if (!validateUser() || !selectedUser) return; + + const name = `${selectedUser.firstName} ${selectedUser.lastName}`; + + if (type === "BCAN") { + dispatch({ + type: "SET_FIELD", + field: "bcanPocEmail", + value: selectedUser.email, + }); + dispatch({ type: "SET_FIELD", field: "bcanPocName", value: name }); + } else { + dispatch({ + type: "SET_FIELD", + field: "grantProviderPocEmail", + value: selectedUser.email, + }); + dispatch({ + type: "SET_FIELD", + field: "grantProviderPocName", + value: name, + }); + } + + setShowPopup(); + }; + + return ( +
+
+
Add Contact
+ + {/* Contact Type Selector */} +
+ {(["BCAN", "Granter"] as ContactType[]).map((t) => ( + + ))} +
+ + {/* BCAN USER PICKER */} + {type === "BCAN" && ( +
+ + +
+ {activeUsers.map((user) => ( +
setSelectedUser(user)} + className={`p-2 rounded cursor-pointer border border-grey-300 + ${ + selectedUser?.email === user.email + ? "border-primary-900" + : "hover:bg-grey-100" + }`} + > +
+ Profile +
+
+ {user.firstName} {user.lastName} +
+ +
{user.email}
+
+
+
+ ))} +
+ + {selectedUser && ( +
+ Selected: {selectedUser.firstName} {selectedUser.lastName} +
+ )} +
+ )} + + {/* GRANTER FORM */} + {type === "Granter" && ( +
+
+ { + setSelectedUser({ + ...selectedUser, + firstName: e.target.value, + }); + }} + /> + + { + setSelectedUser({ + ...selectedUser, + lastName: e.target.value, + }); + }} + /> +
+ + { + setSelectedUser({ ...selectedUser, email: e.target.value }); + }} + /> +
+ )} + + {(error) && ( +
+ {"Please enter a valid email."} +
+ )} + + {/* ACTION BUTTONS */} +
+
+
+
+ ); + }, +); + +export default AddContactPopup; diff --git a/frontend/src/main-page/grants/edit-grant/components/EditGrantContacts.tsx b/frontend/src/main-page/grants/edit-grant/components/EditGrantContacts.tsx new file mode 100644 index 00000000..8fe71628 --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/EditGrantContacts.tsx @@ -0,0 +1,94 @@ +import Button from "../../../../components/Button"; +import { faPlusCircle } from "@fortawesome/free-solid-svg-icons"; +import ContactCard from "../../grant-view/components/ContactCard"; +import { GrantFormState } from "../EditGrant"; +import EditGrantDeleteItem from "./EditGrantDeleteItem"; +import { Action } from "../processGrantDataEditSave"; +import { useState } from "react"; +import AddContactPopup from "./AddContactPopup"; +import { observer } from "mobx-react-lite"; + +type EditGrantProps = { + form: GrantFormState; + dispatch: React.Dispatch; +}; + +const EditGrantContacts = ({ dispatch, form }: EditGrantProps) => { + const [showPopup, setShowPopup] = useState(false); + + const onDelete = (type: "BCAN" | "Granter") => { + const prefix = type === "BCAN" ? "bcanPoc" : "grantProviderPoc"; + + dispatch({ + type: "SET_FIELD", + field: `${prefix}Email`, + value: "", + }); + + dispatch({ + type: "SET_FIELD", + field: `${prefix}Name`, + value: "", + }); + }; + + return ( +
+ + +
+ {form.bcanPocEmail !== "" && ( + + } + onDelete={() => onDelete("BCAN")} + /> + )} + {!(form.bcanPocEmail && form.grantProviderPocEmail) && ( +
+
+ ); +}; + +export default observer(EditGrantContacts); \ No newline at end of file diff --git a/frontend/src/main-page/grants/edit-grant/components/EditGrantDeleteItem.tsx b/frontend/src/main-page/grants/edit-grant/components/EditGrantDeleteItem.tsx new file mode 100644 index 00000000..8d80a118 --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/EditGrantDeleteItem.tsx @@ -0,0 +1,25 @@ +import { FaXmark } from "react-icons/fa6"; + +type EditGrantDeleteItemProps = { + item: React.ReactNode; + onDelete: () => void; + position?: 'middle' | 'top' +}; + +export default function EditGrantDeleteItem({ + item, + onDelete, + position = "middle", +}: EditGrantDeleteItemProps) { + return ( +
+
+ +
+ {item} +
+ ); +} diff --git a/frontend/src/main-page/grants/edit-grant/components/EditGrantDocuments.tsx b/frontend/src/main-page/grants/edit-grant/components/EditGrantDocuments.tsx new file mode 100644 index 00000000..059221da --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/EditGrantDocuments.tsx @@ -0,0 +1,60 @@ +import Button from "../../../../components/Button"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { GrantFormState } from "../EditGrant"; +import { Action } from "../processGrantDataEditSave"; +import EditGrantDeleteItem from "./EditGrantDeleteItem"; +import { useState } from "react"; +import AddAttachmentPopup from "./AddAttachmentPopup"; + +type EditGrantProps = { + form: GrantFormState; + dispatch: React.Dispatch; +}; + +export const EditGrantDocuments = ({ dispatch, form }: EditGrantProps) => { + +const [showPopup, setShowPopup] = useState(false); + + return ( +
+ +
+ {form.attachments.map((attachment, index) => ( + + + {attachment.attachment_name || attachment.url} + +
+ } + onDelete={() => + dispatch({ type: "REMOVE_ATTACHMENT", index: index }) + } + /> + ))} +
+
+ ); +}; diff --git a/frontend/src/main-page/grants/edit-grant/components/EditGrantHeader.tsx b/frontend/src/main-page/grants/edit-grant/components/EditGrantHeader.tsx new file mode 100644 index 00000000..4926976e --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/EditGrantHeader.tsx @@ -0,0 +1,59 @@ +import { GrantFormState } from "../EditGrant"; +import { Action } from "../processGrantDataEditSave"; +import StatusIndicator from "../../grant-view/components/StatusIndicator"; +import { Status } from "../../../../../../middle-layer/types/Status"; + +type EditGrantProps = { + form: GrantFormState; + dispatch: React.Dispatch; +}; + +const buttonOptions = [ + { id: Status.Active, label: "Active" }, + { id: Status.Pending, label: "Pending" }, + { id: Status.Potential, label: "Potential" }, + { id: Status.Rejected, label: "Rejected" }, + { id: Status.Inactive, label: "Inactive" }, +]; + +export default function EditGrantHeader({ form, dispatch }: EditGrantProps) { + return ( +
+
+ { + if (e.key === "Enter") e.preventDefault(); + }} + onChange={(e) => + dispatch({ + type: "SET_FIELD", + field: "organization", + value: e.target.value, + }) + } + /> +
+ {/* 5 Horizontal Buttons */} +
+ {buttonOptions.map((btn) => ( +
+ + dispatch({ + type: "SET_FIELD", + field: "status", + value: btn.id, + }) + } + /> +
+ ))} +
+
+ ); +} diff --git a/frontend/src/main-page/grants/edit-grant/components/EditGrantInfo.tsx b/frontend/src/main-page/grants/edit-grant/components/EditGrantInfo.tsx new file mode 100644 index 00000000..a6d7e6d7 --- /dev/null +++ b/frontend/src/main-page/grants/edit-grant/components/EditGrantInfo.tsx @@ -0,0 +1,243 @@ +import Button from "../../../../components/Button"; +import { GrantFormState } from "../EditGrant"; +import { TDateISO } from "../../../../../../backend/src/utils/date"; +import { Action } from "../processGrantDataEditSave"; +import { + faCheckSquare, + faPlus, + faSquareXmark, +} from "@fortawesome/free-solid-svg-icons"; +import EditGrantDeleteItem from "./EditGrantDeleteItem"; + +type EditGrantProps = { + form: GrantFormState; + dispatch: React.Dispatch; +}; + +export default function EditGrantInfo({ form, dispatch }: EditGrantProps) { + return ( +
+
+ +