diff --git a/apps/frontend/src/app/accounts/page.tsx b/apps/frontend/src/app/accounts/page.tsx index 5bd1f7a5..06fd6119 100644 --- a/apps/frontend/src/app/accounts/page.tsx +++ b/apps/frontend/src/app/accounts/page.tsx @@ -1,14 +1,16 @@ -'use client'; +"use client"; import React, { useState } from 'react'; +import { useUsers } from '@/hooks/useUsers'; import StaffCard from '../components/StaffCard'; import AddUserModal from '../components/AddUserModal'; import { Button } from '@chakra-ui/react'; -import { facilitationTeam, teamMembers } from './mockUsers'; export default function AccountsPage() { + const { users, loading, error } = useUsers(); + const shownFacilitation = users.filter(u => u.is_admin); + const shownTeam = users.filter(u => !u.is_admin); const [isModalOpen, setIsModalOpen] = useState(false); - return (
@@ -23,13 +25,15 @@ export default function AccountsPage() {

Core BRANCH Facilitation Team

- {facilitationTeam.map(user => ( + {loading &&

Loading users...

} + {error &&

{error}

} + {!loading && !error && shownFacilitation.map(user => ( ))}

BRANCH Team Members

- {teamMembers.map(user => ( + {!loading && !error && shownTeam.map(user => ( ))}
diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 31014556..30137830 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -1,44 +1,34 @@ 'use client' -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import NavBar from "../components/Navbar"; import { HStack, Input, Button, 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 } from "react-icons/fa"; +import { FaPlus, FaAngleLeft, FaAngleRight } from "react-icons/fa"; import DropdownSelector from '../components/DropdownSelector'; import DataTable, { type DataTableColumn } from '../components/DataTable'; -import Pagination from '../components/Pagination'; type Donation = { donor_id: number; - date: string | null; + donated_at: string | null; + project_id: number; project_name: string; - amount: number; + amount: number | string; }; +import { useApi } from '@/hooks/useApi'; + +type ApiDonation = Omit; +type ApiDonor = { donor_id: number; organization: string }; +type ApiProject = { project_id: number; name: string }; -const mockDonors = ['Green Future Foundation', 'Horizon Trust', 'Bright Path Nonprofit', 'Unity Giving Circle', 'Sunrise Community Fund']; -const mockProjects = ['Clean Water Initiative', 'Youth Mentorship Program', 'Food Security Drive', 'Urban Garden Project', 'STEM Education Fund']; - -const mockDonations: Donation[] = [ - { donor_id: 1, date: '03/12/2024', project_name: 'Clean Water Initiative', amount: 5000 }, - { donor_id: 2, date: '01/05/2024', project_name: 'Youth Mentorship Program', amount: 12000 }, - { donor_id: 3, date: '02/28/2024', project_name: 'Food Security Drive', amount: 750 }, - { donor_id: 4, date: '03/30/2024', project_name: 'Urban Garden Project', amount: 3200 }, - { donor_id: 5, date: '04/01/2024', project_name: 'STEM Education Fund', amount: 8500 }, - { donor_id: 6, date: '02/14/2024', project_name: 'Shelter Renovation', amount: 1500 }, - { donor_id: 7, date: '01/20/2024', project_name: 'Mental Health Outreach', amount: 20000 }, - { donor_id: 8, date: '03/05/2024', project_name: 'Digital Literacy Program', amount: 9750 }, - { donor_id: 9, date: '04/10/2024', project_name: 'Community Health Fair', amount: 4300 }, - { donor_id: 10, date: '03/22/2024', project_name: 'After-School Arts', amount: 600 }, -]; const donationColumns: DataTableColumn[] = [ { - key: 'date', + key: 'donated_at', header: 'Date', width: '15%', - cell: (donation) => donation.date ?? '—', + cell: (donation) => donation.donated_at ?? '—', skeleton: { width: '70%' }, }, { @@ -53,7 +43,7 @@ const donationColumns: DataTableColumn[] = [ key: 'amount', header: 'Amount', width: '15%', - cell: (donation) => `$${donation.amount.toLocaleString()}`, + cell: (donation) => `$${Number(donation.amount).toLocaleString()}`, skeleton: { width: '55%' }, }, ]; @@ -62,12 +52,59 @@ export default function DonationsPage() { const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; - const totalPages = Math.ceil(mockDonations.length / rowsPerPage); - const currentDonations = mockDonations.slice( + const api = useApi(); + const [donations, setDonations] = useState([]); + const [donorNames, setDonorNames] = useState([]); + const [projectNames, setProjectNames] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadAll() { + try { + const [donationsJson, donorsJson, projectsJson] = await Promise.all([ + api.get('/donors/donations'), + api.get('/donors'), + api.get('/projects'), + ]); + + const donations = Array.isArray(donationsJson) ? donationsJson : donationsJson.data; + const donors = Array.isArray(donorsJson) ? donorsJson : donorsJson.data; + const projects = projectsJson; + const projectNamesById = new Map(projects.map((project) => [project.project_id, project.name])); + + setDonations(donations.map((donation) => ({ + ...donation, + project_name: projectNamesById.get(donation.project_id) ?? `Project #${donation.project_id}`, + }))); + setDonorNames(donors.map((donor) => donor.organization)); + setProjectNames(projects.map((project) => project.name)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load donations data'); + setDonations([]); + setDonorNames([]); + setProjectNames([]); + } finally { + setLoading(false); + } + } + loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const totalPages = Math.max(1, Math.ceil(donations.length / rowsPerPage)); + const currentDonations = donations.slice( (currentPage - 1) * rowsPerPage, currentPage * rowsPerPage ); + const getPageNumbers = (): Array => { + if (totalPages <= 5) return Array.from({ length: totalPages }, (_, index) => index + 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 [showSort, setShowSort] = useState(false); @@ -124,7 +161,7 @@ export default function DonationsPage() { {showFilter && (
{showSort && (
- setSelectedSort(val as string)} - /> + setSelectedSort(val as string)} + />
)}
@@ -194,7 +231,7 @@ export default function DonationsPage() { {dateError && Enter a valid date}
{donorError && Select a donor} - donation.donor_id} - emptyMessage="No donations found." - /> - - + {error &&

{error}

} + {!error && ( + donation.donor_id} + isLoading={loading} + skeletonRows={rowsPerPage} + emptyMessage="No donations found." + /> + )} + + setCurrentPage((page) => Math.max(page - 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((page) => Math.min(page + 1, totalPages))} + style={{ cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages ? 0.3 : 1, color: 'var(--color-core-green)' }} + /> + diff --git a/apps/frontend/src/app/donors/page.tsx b/apps/frontend/src/app/donors/page.tsx index 4014e291..b1677ab7 100644 --- a/apps/frontend/src/app/donors/page.tsx +++ b/apps/frontend/src/app/donors/page.tsx @@ -1,36 +1,22 @@ 'use client' -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import NavBar from "../components/Navbar"; import { HStack, Input, Button, 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 } from "react-icons/fa"; +import { FaPlus, FaAngleLeft, FaAngleRight } from "react-icons/fa"; import DropdownSelector from '../components/DropdownSelector'; import DataTable, { type DataTableColumn } from '../components/DataTable'; -import Pagination from '../components/Pagination'; type Donor = { donor_id: number; organization: string; contact_name: string | null; contact_email: string | null; - num_projects: number; - last_donation: string | null; }; +import { useApi } from '@/hooks/useApi'; -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' }, -]; const donorColumns: DataTableColumn[] = [ { @@ -40,19 +26,19 @@ const donorColumns: DataTableColumn[] = [ cell: (donor) => `#${String(donor.donor_id).padStart(6, '0')}`, skeleton: { width: '80%' }, }, - { key: 'organization', header: 'Donor Name', width: '55%', cell: (donor) => donor.organization }, + { key: 'organization', header: 'Donor Name', width: '35%', cell: (donor) => donor.organization }, { - key: 'projects', - header: '# of Projects', - width: '15%', - cell: (donor) => donor.num_projects, - skeleton: { width: '35%' }, + key: 'contact_name', + header: 'Contact Name', + width: '25%', + cell: (donor) => donor.contact_name ?? '—', + skeleton: { width: '70%' }, }, { - key: 'last_donation', - header: 'Last Donation', - width: '15%', - cell: (donor) => donor.last_donation ?? '—', + key: 'contact_email', + header: 'Contact Email', + width: '25%', + cell: (donor) => donor.contact_email ?? '—', skeleton: { width: '70%' }, }, ]; @@ -61,15 +47,44 @@ export default function DonorsPage() { const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; - const totalPages = Math.ceil(mockDonors.length / rowsPerPage); - const currentDonors = mockDonors.slice( + const api = useApi(); + const [donors, setDonors] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDonors() { + try { + const json = await api.get('/donors'); + const list = Array.isArray(json) ? json : (json && 'data' in json ? json.data : []); + setDonors(list); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load donors'); + setDonors([]); + } finally { + setLoading(false); + } + } + fetchDonors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const totalPages = Math.max(1, Math.ceil(donors.length / rowsPerPage)); + const currentDonors = donors.slice( (currentPage - 1) * rowsPerPage, currentPage * rowsPerPage ); + const getPageNumbers = (): Array => { + if (totalPages <= 5) return Array.from({ length: totalPages }, (_, index) => index + 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 donorNames = donors.map(d => d.organization); const [showSort, setShowSort] = useState(false); const [selectedSort, setSelectedSort] = useState(''); @@ -206,18 +221,32 @@ export default function DonorsPage() { - donor.donor_id} - emptyMessage="No donors found." - /> - - + {error &&

{error}

} + {!error && ( + donor.donor_id} + isLoading={loading} + skeletonRows={rowsPerPage} + emptyMessage="No donors found." + /> + )} + + setCurrentPage((page) => Math.max(page - 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((page) => Math.min(page + 1, totalPages))} + style={{ cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages ? 0.3 : 1, color: 'var(--color-core-green)' }} + /> + diff --git a/apps/frontend/src/hooks/useUsers.ts b/apps/frontend/src/hooks/useUsers.ts new file mode 100644 index 00000000..5c3256c7 --- /dev/null +++ b/apps/frontend/src/hooks/useUsers.ts @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useApi } from '@/hooks/useApi'; +import { User } from '@/types'; + +type UsersResponse = { + users?: User[]; + data?: User[]; + pagination?: { + page: number; + limit: number; + totalUsers: number; + totalPages: number; + }; +}; + +export function useUsers() { + const api = useApi(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchUsers() { + try { + const json = await api.get('/users'); + + const list = Array.isArray(json) + ? json + : Array.isArray(json.users) + ? json.users + : Array.isArray(json.data) + ? json.data + : []; + + if (!cancelled) { + setUsers(list); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + setUsers([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + fetchUsers(); + + return () => { + cancelled = true; + }; + }, [api]); + + return { users, loading, error }; +} diff --git a/apps/frontend/test/components/AccountsPage.test.tsx b/apps/frontend/test/components/AccountsPage.test.tsx index 2273c25f..9344894a 100644 --- a/apps/frontend/test/components/AccountsPage.test.tsx +++ b/apps/frontend/test/components/AccountsPage.test.tsx @@ -1,8 +1,30 @@ -import { render, screen } from '../utils'; +import { render, screen, waitFor } from '../utils'; import AccountsPage from '@/app/accounts/page'; import { facilitationTeam, teamMembers } from '@/app/accounts/mockUsers'; describe('AccountsPage', () => { + beforeEach(() => { + localStorage.setItem('branch_access_token', 'fake.access.token'); + localStorage.setItem('branch_id_token', 'fake.id.token'); + localStorage.setItem('branch_refresh_token', 'fake.refresh'); + + global.fetch = jest.fn().mockImplementation((input: RequestInfo) => { + const url = typeof input === 'string' ? input : (input as Request).url; + if (url.includes('/auth/me')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ userId: 1, cognitoSub: 'sub-test', email: 'test@example.com', name: 'Test User', isAdmin: false }) } as unknown as Response); + } + if (url.includes('/users')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ users: [...facilitationTeam, ...teamMembers] }) } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200, json: async () => ({}) } as unknown as Response); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + it('renders the headings', () => { render(); expect(screen.getByText('Accounts')).toBeInTheDocument(); @@ -10,27 +32,25 @@ describe('AccountsPage', () => { expect(screen.getByText('BRANCH Team Members')).toBeInTheDocument(); }); - it('renders the correct staff cards in the facilitation section', () => { + it('renders the correct staff cards in the facilitation section', async () => { render(); - const section = screen.getByText('Core BRANCH Facilitation Team').closest('div'); - const cards = section?.querySelectorAll('[data-testid="staff-card"]'); - - if (facilitationTeam.length === 0) { - expect(cards?.length).toBe(0); - } else { - expect(cards?.length).toBeGreaterThan(0); - } + const sectionHeader = await screen.findByText('Core BRANCH Facilitation Team'); + await waitFor(() => { + const section = sectionHeader.nextElementSibling; + const cards = section?.querySelectorAll('[data-testid="staff-card"]'); + + expect(cards?.length).toBe(facilitationTeam.length); + }); }); - it('renders the correct staff cards in the team members section', () => { + it('renders the correct staff cards in the team members section', async () => { render(); - const section = screen.getByText('BRANCH Team Members').closest('div'); - const cards = section?.querySelectorAll('[data-testid="staff-card"]'); - - if (teamMembers.length === 0) { - expect(cards?.length).toBe(0); - } else { - expect(cards?.length).toBeGreaterThan(0); - } + const sectionHeader = await screen.findByText('BRANCH Team Members'); + await waitFor(() => { + const section = sectionHeader.nextElementSibling; + const cards = section?.querySelectorAll('[data-testid="staff-card"]'); + + expect(cards?.length).toBe(teamMembers.length); + }); }); }); \ No newline at end of file diff --git a/apps/frontend/test/components/Donations.test.tsx b/apps/frontend/test/components/Donations.test.tsx index 1ae3c91e..dd534973 100644 --- a/apps/frontend/test/components/Donations.test.tsx +++ b/apps/frontend/test/components/Donations.test.tsx @@ -2,6 +2,35 @@ import { render, screen, fireEvent, waitFor } from '../utils'; import Donations from '@/app/donations/page'; describe('Donations Page Component', () => { + beforeEach(() => { + // seed tokens expected by the app + localStorage.setItem('branch_access_token', 'fake.access.token'); + localStorage.setItem('branch_id_token', 'fake.id.token'); + localStorage.setItem('branch_refresh_token', 'fake.refresh'); + + global.fetch = jest.fn().mockImplementation((input: RequestInfo) => { + const url = typeof input === 'string' ? input : (input as Request).url; + if (url.includes('/auth/me')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ userId: 1, cognitoSub: 'sub-test', email: 'test@example.com', name: 'Test User', isAdmin: false }) } as unknown as Response); + } + if (url.includes('/donations')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: [{ donation_id: 1, donor_id: 1, project_id: 1, donated_at: '2026-01-01', amount: 100 }] }) } as unknown as Response); + } + if (url.includes('/donors')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: [{ donor_id: 1, organization: 'Org A' }] }) } as unknown as Response); + } + if (url.includes('/projects')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ([{ project_id: 1, name: 'Proj Alpha' }]) } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200, json: async () => [] } as unknown as Response); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + it('renders the donations heading', () => { render(); expect(screen.getByText('Donations', { selector: 'h1' })).toBeInTheDocument(); @@ -19,12 +48,12 @@ describe('Donations Page Component', () => { expect(screen.getByText('New Donation')).toBeInTheDocument(); }); - it('renders the table with correct headers', () => { + it('renders the table with correct headers', async () => { render(); - expect(screen.getByText('Date')).toBeInTheDocument(); - expect(screen.getByText('Donor ID')).toBeInTheDocument(); - expect(screen.getByText('Project Name')).toBeInTheDocument(); - expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(await screen.findByText('Date')).toBeInTheDocument(); + expect(await screen.findByText('Donor ID')).toBeInTheDocument(); + expect(await screen.findByText('Project Name')).toBeInTheDocument(); + expect(await screen.findByText('Amount')).toBeInTheDocument(); }); it('renders left and right pagination arrows', () => { diff --git a/apps/frontend/test/components/Donors.test.tsx b/apps/frontend/test/components/Donors.test.tsx index dda34b53..ace09c00 100644 --- a/apps/frontend/test/components/Donors.test.tsx +++ b/apps/frontend/test/components/Donors.test.tsx @@ -2,6 +2,28 @@ import { render, screen, fireEvent, waitFor } from '../utils'; import Donors from '@/app/donors/page'; describe('Donors Page', () => { + beforeEach(() => { + localStorage.setItem('branch_access_token', 'fake.access.token'); + localStorage.setItem('branch_id_token', 'fake.id.token'); + localStorage.setItem('branch_refresh_token', 'fake.refresh'); + + global.fetch = jest.fn().mockImplementation((input: RequestInfo) => { + const url = typeof input === 'string' ? input : (input as Request).url; + if (url.includes('/auth/me')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ userId: 1, cognitoSub: 'sub-test', email: 'test@example.com', name: 'Test User', isAdmin: false }) } as unknown as Response); + } + if (url.includes('/donors')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: [{ donor_id: 1, organization: 'Org A', contact_name: null, contact_email: null }] }) } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200, json: async () => [] } as unknown as Response); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + it('renders the Donors heading', () => { render(); expect(screen.getByText('Donors', { selector: 'h1' })).toBeInTheDocument(); @@ -19,12 +41,12 @@ describe('Donors Page', () => { expect(screen.getByText('New Donor')).toBeInTheDocument(); }); - it('renders the table with correct headers', () => { + it('renders the table with correct headers', async () => { 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(); + expect(await screen.findByText('Donor ID')).toBeInTheDocument(); + expect(await screen.findByText('Donor Name')).toBeInTheDocument(); + expect(await screen.findByText('Contact Name')).toBeInTheDocument(); + expect(await screen.findByText('Contact Email')).toBeInTheDocument(); }); it('renders left and right pagination arrows', () => {