diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 3761ddf..16f1e66 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -11,7 +11,8 @@ "@chakra-ui/react": "^3.33.0", "next": "15.5.4", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-icons": "^5.6.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -10328,6 +10329,15 @@ "react": "^19.1.0" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index fa067a0..e32d5e8 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -14,7 +14,8 @@ "@chakra-ui/react": "^3.33.0", "next": "15.5.4", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-icons": "^5.6.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/apps/frontend/src/app/Donors.tsx b/apps/frontend/src/app/Donors.tsx new file mode 100644 index 0000000..7141fdb --- /dev/null +++ b/apps/frontend/src/app/Donors.tsx @@ -0,0 +1,253 @@ +'use client' +import React, { useState } from 'react'; +import { HStack, Input, Button, Table, Dialog, Portal, CloseButton, Stack } from "@chakra-ui/react"; +import TextInputField from './components/TextInputField'; +import { CiFilter } from "react-icons/ci"; +import { LuArrowDownUp } from "react-icons/lu"; +import { FaPlus, FaAngleLeft, FaAngleRight } from "react-icons/fa"; +import DropdownSelector from './components/DropdownSelector'; +type Donor = { + donor_id: number; + organization: string; + contact_name: string | null; + contact_email: string | null; + num_projects: number; + last_donation: string | null; +}; + +const mockDonors: Donor[] = [ + { donor_id: 1, organization: 'Green Future Foundation', contact_name: 'Alice Chen', contact_email: 'alice@greenfuture.org', num_projects: 4, last_donation: '03/12/2024' }, + { donor_id: 2, organization: 'Horizon Trust', contact_name: 'James Patel', contact_email: 'james@horizontrust.org', num_projects: 2, last_donation: '01/05/2024' }, + { donor_id: 3, organization: 'Bright Path Nonprofit', contact_name: null, contact_email: null, num_projects: 7, last_donation: '02/28/2024' }, + { donor_id: 4, organization: 'Unity Giving Circle', contact_name: 'Maria Lopez', contact_email: 'maria@unitygiving.org', num_projects: 1, last_donation: '03/30/2024' }, + { donor_id: 5, organization: 'Sunrise Community Fund', contact_name: 'David Kim', contact_email: 'david@sunrisefund.org', num_projects: 3, last_donation: '04/01/2024' }, + { donor_id: 6, organization: 'Blue Ridge Giving', contact_name: 'Sarah Thompson', contact_email: 'sarah@blueridge.org', num_projects: 5, last_donation: '02/14/2024' }, + { donor_id: 7, organization: 'Maple Leaf Charitable Trust', contact_name: null, contact_email: null, num_projects: 2, last_donation: '01/20/2024' }, + { donor_id: 8, organization: 'Evergreen Partners', contact_name: 'Rachel Singh', contact_email: 'rachel@evergreenpartners.org', num_projects: 6, last_donation: '03/05/2024' }, + { donor_id: 9, organization: 'New Horizons Society', contact_name: 'Tom Bradley', contact_email: 'tom@newhorizons.org', num_projects: 9, last_donation: '04/10/2024' }, + { donor_id: 10, organization: 'Coastal Care Foundation', contact_name: 'Nina Rossi', contact_email: 'nina@coastalcare.org', num_projects: 3, last_donation: '03/22/2024' }, +]; + +export default function Donors() { + + const [currentPage, setCurrentPage] = useState(1); + const rowsPerPage = 10; + + const totalPages = Math.ceil(mockDonors.length / rowsPerPage); + const currentDonors = mockDonors.slice( + (currentPage - 1) * rowsPerPage, + currentPage * rowsPerPage + ); + + const getPageNumbers = () => { + if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1); + + if (currentPage <= 3) return [1, 2, 3, '...', totalPages]; + if (currentPage >= totalPages - 2) return [1, '...', totalPages - 2, totalPages - 1, totalPages]; + return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages]; + }; + + const [showFilter, setShowFilter] = useState(false); + const [selectedDonor, setSelectedDonor] = useState(''); + const donorNames = mockDonors.map(d => d.organization); + + const [showSort, setShowSort] = useState(false); + const[selectedSort, setSelectedSort] = useState(''); + const sortOptions = ['# of Projects', 'Last Donated'] + + const [showNewDonor, setShowNewDonor] = useState(false); + const [newOrganization, setNewOrganization] = useState(''); + const [newContactName, setNewContactName] = useState(''); + const [newContactEmail, setNewContactEmail] = useState(''); + const [orgError, setOrgError] = useState(false); + const [nameError, setNameError] = useState(false); + const [emailError, setEmailError] = useState(false); + const handleSave = () => { + const hasOrgError = !newOrganization.trim(); + const hasNameError = !newContactName.trim(); + const hasEmailError = !newContactEmail.trim(); + + setOrgError(hasOrgError); + setNameError(hasNameError); + setEmailError(hasEmailError); + + if (hasOrgError || hasNameError || hasEmailError) return; + + // need to implement the save logic later (like saving it to the db) + + setShowNewDonor(false); + }; + + return ( +
+

Donors

+ + + + + +
+ + {showFilter && ( +
+ setSelectedDonor(val as string)} + /> +
+ )} +
+
+ + {showSort && ( +
+ setSelectedSort(val as string)} + /> +
+ )} +
+ +
+
+ + setShowNewDonor(e.open)}> + + + + + + Add New Donor + setShowNewDonor(false)} /> + + + + { setNewOrganization(val); setOrgError(false); }} + isError={orgError} + errorMessage="Enter valid name" + /> + { setNewContactName(val); setNameError(false); }} + isError={nameError} + errorMessage="Enter valid name" + /> + { setNewContactEmail(val); setEmailError(false); }} + isError={emailError} + errorMessage="Enter valid email" + /> + + + + + + + + + + + + + + + + + + + + + + Donor ID + Donor Name + # of Projects + Last Donation + + + + + {currentDonors.map((donor) => ( + + #{String(donor.donor_id).padStart(6, '0')} + {donor.organization} + {donor.num_projects} + {donor.last_donation ?? '—'} + + ))} + + + + +
+ + setCurrentPage(p => Math.max(p - 1, 1))} + style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer', opacity: currentPage === 1 ? 0.3 : 1, color: 'var(--color-core-green)' }} + /> + {getPageNumbers().map((page, index) => ( + page === '...' + ? + : + ))} + setCurrentPage(p => Math.min(p + 1, totalPages))} + style={{ cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages ? 0.3 : 1, color: 'var(--color-core-green)' }} + /> + +
+
+ ) +} \ No newline at end of file diff --git a/apps/frontend/test/components/Donors.test.tsx b/apps/frontend/test/components/Donors.test.tsx new file mode 100644 index 0000000..e8d46e9 --- /dev/null +++ b/apps/frontend/test/components/Donors.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent, waitFor } from '../utils'; +import Donors from '@/app/Donors'; + +describe('Login Page Component', () => { + it('renders the login heading', () => { + render(); + expect(screen.getByText('Donors', { selector: 'h1' })).toBeInTheDocument(); + }); + + it('renders the search input', () => { + render(); + expect(screen.getByPlaceholderText('🔍︎ Search...')).toBeInTheDocument(); + }); + + it('renders Filter By, Sort By, and New Donor buttons', () => { + render(); + expect(screen.getByText('Filter By')).toBeInTheDocument(); + expect(screen.getByText('Sort By')).toBeInTheDocument(); + expect(screen.getByText('New Donor')).toBeInTheDocument(); + }); + + it('renders the table with correct headers', () => { + render(); + expect(screen.getByText('Donor ID')).toBeInTheDocument(); + expect(screen.getByText('Donor Name')).toBeInTheDocument(); + expect(screen.getByText('# of Projects')).toBeInTheDocument(); + expect(screen.getByText('Last Donation')).toBeInTheDocument(); + }); + + it('renders left and right pagination arrows', () => { + render(); + expect(document.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders left and right pagination arrows', () => { + render(); + expect(document.querySelector('svg')).toBeInTheDocument(); + }); + + it('shows filter dropdown when Filter By is clicked', () => { + render(); + fireEvent.click(screen.getByText('Filter By')); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('shows sort dropdown when Sort By is clicked', () => { + render(); + fireEvent.click(screen.getByText('Sort By')); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('shows new donor modal when New Donor is clicked', async () => { + render(); + fireEvent.click(screen.getByText('New Donor')); + await waitFor(() => { + expect(screen.getByText('Add New Donor')).toBeInTheDocument(); + }); + }); +}) \ No newline at end of file