From 19f9f9a8a7a5dcdc76d3ca8808a11ade5621cd89 Mon Sep 17 00:00:00 2001 From: mehanana Date: Sat, 22 Aug 2026 12:15:38 -0400 Subject: [PATCH 1/4] changes for donations page to match figma --- .../frontend/src/app/components/DataTable.tsx | 10 +- .../src/app/components/DropdownSelector.tsx | 3 +- .../src/app/components/TextInputField.tsx | 19 +- apps/frontend/src/app/donations/page.tsx | 281 +++++++++++++++--- apps/frontend/src/lib/api.ts | 1 + 5 files changed, 261 insertions(+), 53 deletions(-) diff --git a/apps/frontend/src/app/components/DataTable.tsx b/apps/frontend/src/app/components/DataTable.tsx index b9f9357c..872b2f64 100644 --- a/apps/frontend/src/app/components/DataTable.tsx +++ b/apps/frontend/src/app/components/DataTable.tsx @@ -89,7 +89,7 @@ export default function DataTable({ ]; return ( - + {hasWidths && ( {selection && } @@ -182,7 +182,13 @@ export default function DataTable({ )} {columns.map((column) => ( - + {column.cell(row)} ))} diff --git a/apps/frontend/src/app/components/DropdownSelector.tsx b/apps/frontend/src/app/components/DropdownSelector.tsx index 59aebf5e..37d2cf6e 100644 --- a/apps/frontend/src/app/components/DropdownSelector.tsx +++ b/apps/frontend/src/app/components/DropdownSelector.tsx @@ -83,8 +83,7 @@ export default function DropdownSelector({ - - {collection.items.map((item) => + {collection.items.map((item) => multiSelect ? ( )} + {icon && ( + + {icon} + + )} ); -} +} \ No newline at end of file diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 31014556..4ce36e3a 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -1,38 +1,58 @@ 'use client' -import React, { useState } from 'react'; +import React, { useEffect, useRef, 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 { FiDollarSign } from "react-icons/fi"; import DropdownSelector from '../components/DropdownSelector'; import DataTable, { type DataTableColumn } from '../components/DataTable'; import Pagination from '../components/Pagination'; +import { useApi } from '@/hooks/useApi'; type Donation = { + donation_id: number; donor_id: number; - date: string | null; + project_id: number; + donor_name: string; project_name: string; amount: number; + date: string | null; }; -const mockDonors = ['Green Future Foundation', 'Horizon Trust', 'Bright Path Nonprofit', 'Unity Giving Circle', 'Sunrise Community Fund']; +/** + * Raw shape of a row from GET /donations. Donor/project are ids, not names β€” + * names are resolved against the mock arrays below, since real /donors and + * /projects fetches are out of scope for this ticket. + */ +interface RawDonation { + donation_id: number; + donor_id: number; + project_id: number; + amount: string; + donated_at: string; +} + +const mockDonors = ['Green Future Foundation really long name htat overfloaws and we dont wan tto see it', '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 }, + { donation_id: 1, donor_id: 1, project_id: 1, donor_name: mockDonors[0], project_name: mockProjects[0], date: '03/12/2024', amount: 5000 }, + { donation_id: 2, donor_id: 2, project_id: 2, donor_name: mockDonors[1], project_name: mockProjects[1], date: '01/05/2024', amount: 12000 }, + { donation_id: 3, donor_id: 3, project_id: 3, donor_name: mockDonors[2], project_name: mockProjects[2], date: '02/28/2024', amount: 750 }, + { donation_id: 4, donor_id: 4, project_id: 4, donor_name: mockDonors[3], project_name: mockProjects[3], date: '03/30/2024', amount: 3200 }, + { donation_id: 5, donor_id: 5, project_id: 5, donor_name: mockDonors[4], project_name: mockProjects[4], date: '04/01/2024', amount: 8500 }, + { donation_id: 6, donor_id: 1, project_id: 2, donor_name: mockDonors[0], project_name: mockProjects[1], date: '02/14/2024', amount: 1500 }, + { donation_id: 7, donor_id: 2, project_id: 3, donor_name: mockDonors[1], project_name: mockProjects[2], date: '01/20/2024', amount: 20000 }, + { donation_id: 8, donor_id: 3, project_id: 4, donor_name: mockDonors[2], project_name: mockProjects[3], date: '03/05/2024', amount: 9750 }, + { donation_id: 9, donor_id: 4, project_id: 5, donor_name: mockDonors[3], project_name: mockProjects[4], date: '04/10/2024', amount: 4300 }, + { donation_id: 10, donor_id: 5, project_id: 1, donor_name: mockDonors[4], project_name: mockProjects[0], date: '03/22/2024', amount: 600 }, ]; +const sortOptions = ['Date', 'Amount']; + const donationColumns: DataTableColumn[] = [ { key: 'date', @@ -43,12 +63,18 @@ const donationColumns: DataTableColumn[] = [ }, { key: 'donor', - header: 'Donor ID', - width: '15%', - cell: (donation) => `#${String(donation.donor_id).padStart(6, '0')}`, + header: 'Donor Name', + width: '30%', + cell: (donation) => donation.donor_name, skeleton: { width: '80%' }, }, - { key: 'project', header: 'Project Name', width: '55%', cell: (donation) => donation.project_name }, + { + key: 'project', + header: 'Project Name', + width: '40%', + cell: (donation) => donation.project_name, + skeleton: { width: '60%' }, + }, { key: 'amount', header: 'Amount', @@ -59,20 +85,97 @@ const donationColumns: DataTableColumn[] = [ ]; export default function DonationsPage() { + const api = useApi(); + + // Table defaults to mock rows. Only replaced with real API results once + // the person actually searches, filters, or sorts. + const [donations, setDonations] = useState(mockDonations); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; - const totalPages = Math.ceil(mockDonations.length / rowsPerPage); - const currentDonations = mockDonations.slice( - (currentPage - 1) * rowsPerPage, - currentPage * rowsPerPage - ); - + const [searchTerm, setSearchTerm] = useState(''); const [showFilter, setShowFilter] = useState(false); - const [selectedDonor, setSelectedDonor] = useState(''); + const [selectedDonors, setSelectedDonors] = useState([]); const [showSort, setShowSort] = useState(false); const [selectedSort, setSelectedSort] = useState(''); - const sortOptions = ['Date', 'Amount']; + + // Debounced so every keystroke doesn't fire a request. + const debounceRef = useRef | null>(null); + + useEffect(() => { + const isDefaultState = + !searchTerm.trim() && selectedDonors.length === 0 && !selectedSort; + + if (isDefaultState) { + setDonations(mockDonations); + setError(null); + setLoading(false); + return; + } + + if (debounceRef.current) clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(async () => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams(); + if (searchTerm.trim()) params.set('search', searchTerm.trim()); + if (selectedSort) params.set('sort', selectedSort.toLowerCase()); + if (selectedDonors.length > 0) { + // NOTE: assumes the backend accepts donor ids as a + // comma-separated list. Names are mapped to their mock + // index + 1 here since donor ids aren't fetched from a + // real /donors call in this ticket's scope. + const donorIds = selectedDonors + .map((name) => mockDonors.indexOf(name) + 1) + .filter((id) => id > 0); + if (donorIds.length > 0) params.set('donor_ids', donorIds.join(',')); + } + + const query = params.toString(); + const res = await api.get<{ data: RawDonation[] }>( + `/donations${query ? `?${query}` : ''}` + ); + const rawDonations = res.data ?? []; + + setDonations( + rawDonations.map((d) => ({ + donation_id: d.donation_id, + donor_id: d.donor_id, + project_id: d.project_id, + donor_name: mockDonors[d.donor_id - 1] ?? 'Unknown Donor', + project_name: mockProjects[d.project_id - 1] ?? 'Unknown Project', + amount: Number(d.amount), + date: d.donated_at ?? null, + })) + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load donations'); + } finally { + setLoading(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchTerm, selectedDonors, selectedSort]); + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, selectedDonors, selectedSort]); + + const totalPages = Math.max(1, Math.ceil(donations.length / rowsPerPage)); + const currentDonations = donations.slice( + (currentPage - 1) * rowsPerPage, + currentPage * rowsPerPage + ); const [showNewDonation, setShowNewDonation] = useState(false); const [newDate, setNewDate] = useState(''); @@ -83,8 +186,29 @@ export default function DonationsPage() { const [donorError, setDonorError] = useState(false); const [projectError, setProjectError] = useState(false); const [amountError, setAmountError] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const isFormValid = + newDate.trim().length > 0 && + newDonor.trim().length > 0 && + newProject.trim().length > 0 && + newAmount.trim().length > 0 && + !isNaN(Number(newAmount)); - const handleSave = () => { + function resetForm() { + setNewDate(''); + setNewDonor(''); + setNewProject(''); + setNewAmount(''); + setDateError(false); + setDonorError(false); + setProjectError(false); + setAmountError(false); + setSubmitError(null); + } + + const handleSave = async () => { const hasDateError = !newDate.trim(); const hasDonorError = !newDonor.trim(); const hasProjectError = !newProject.trim(); @@ -96,9 +220,44 @@ export default function DonationsPage() { setAmountError(hasAmountError); if (hasDateError || hasDonorError || hasProjectError || hasAmountError) return; - setShowNewDonation(false); + + const donorId = mockDonors.indexOf(newDonor) + 1; + const projectId = mockProjects.indexOf(newProject) + 1; + + if (donorId <= 0 || projectId <= 0) { + setSubmitError('Select a valid donor and project.'); + return; + } + + try { + setSubmitting(true); + setSubmitError(null); + + // NOTE: /donations expects snake_case. Backend has no donated_at + // column write yet β€” newDate is validated client-side only, per + // the separate ticket tracking that backend change. + await api.post('/donations', { + donor_id: donorId, + project_id: projectId, + amount: Number(newAmount), + }); + + resetForm(); + setShowNewDonation(false); + // Table still reads from mocks/current query results β€” this + // ticket doesn't refresh the list after adding a donation. + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create donation'); + } finally { + setSubmitting(false); + } }; + function handleCloseModal() { + resetForm(); + setShowNewDonation(false); + } + return (
@@ -107,7 +266,12 @@ export default function DonationsPage() {

Donations

- + setSearchTerm(e.target.value)} + />
@@ -120,6 +284,7 @@ export default function DonationsPage() { > Filter By Donor + {selectedDonors.length > 0 && ` (${selectedDonors.length})`} {showFilter && (
@@ -127,8 +292,8 @@ export default function DonationsPage() { options={mockDonors} placeholder="Filter by donor..." multiSelect={true} - value={selectedDonor} - onChange={(val: string | string[]) => setSelectedDonor(val as string)} + value={selectedDonors} + onChange={(val: string | string[]) => setSelectedDonors(val as string[])} />
)} @@ -163,14 +328,14 @@ export default function DonationsPage() { - setShowNewDonation(e.open)}> + { if (!e.open) handleCloseModal(); }}> Add New Donation - setShowNewDonation(false)} /> + @@ -212,36 +377,58 @@ export default function DonationsPage() { } value={newAmount} onChange={(val) => { setNewAmount(val); setAmountError(false); }} isError={amountError} errorMessage="Enter a valid amount" /> + {submitError && ( +

+ {submitError} +

+ )}
- - + +
- donation.donor_id} - emptyMessage="No donations found." - /> - - + {error &&

{error}

} + + {!error && ( + donation.donation_id} + isLoading={loading} + skeletonRows={rowsPerPage} + emptyMessage="No donations found." + /> + )} + + {!loading && !error && ( + + )}
); -} +} \ No newline at end of file diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index f68fec48..df85f563 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -3,6 +3,7 @@ const SERVICE_PORTS: Record = { users: '3001', projects: '3002', donors: '3003', + donations: '3003', expenditures: '3004', reports: '3005', }; From 72c0e4a5a0905401a5aaf379e590f2be10e0d47f Mon Sep 17 00:00:00 2001 From: mehanana Date: Sat, 22 Aug 2026 20:08:03 -0400 Subject: [PATCH 2/4] updated tests and use the updated POST endpoint that takes in date --- apps/frontend/src/app/donations/page.tsx | 6 +++--- apps/frontend/test/components/Donations.test.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 4ce36e3a..7d258086 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -233,13 +233,13 @@ export default function DonationsPage() { setSubmitting(true); setSubmitError(null); - // NOTE: /donations expects snake_case. Backend has no donated_at - // column write yet β€” newDate is validated client-side only, per - // the separate ticket tracking that backend change. + // NOTE: /donations expects snake_case. donated_at is now accepted + // by the backend, per tech lead confirmation. await api.post('/donations', { donor_id: donorId, project_id: projectId, amount: Number(newAmount), + donated_at: newDate, }); resetForm(); diff --git a/apps/frontend/test/components/Donations.test.tsx b/apps/frontend/test/components/Donations.test.tsx index 1ae3c91e..89de193b 100644 --- a/apps/frontend/test/components/Donations.test.tsx +++ b/apps/frontend/test/components/Donations.test.tsx @@ -22,7 +22,7 @@ describe('Donations Page Component', () => { it('renders the table with correct headers', () => { render(); expect(screen.getByText('Date')).toBeInTheDocument(); - expect(screen.getByText('Donor ID')).toBeInTheDocument(); + expect(screen.getByText('Donor Name')).toBeInTheDocument(); expect(screen.getByText('Project Name')).toBeInTheDocument(); expect(screen.getByText('Amount')).toBeInTheDocument(); }); From 81e802f83747cbe0f54b68adfd51cac409f51c44 Mon Sep 17 00:00:00 2001 From: mehanana Date: Sat, 22 Aug 2026 20:19:06 -0400 Subject: [PATCH 3/4] removed the donor id from table --- apps/frontend/src/app/donations/page.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index e0ea828d..7b3ee1c2 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -128,29 +128,22 @@ export default function DonationsPage() { cell: (donation) => formatDateNumeric(donation.donated_at) || 'β€”', skeleton: { width: '70%' }, }, - { - key: 'donor', - header: 'Donor ID', - width: '15%', - cell: (donation) => `#${String(donation.donor_id).padStart(6, '0')}`, - skeleton: { width: '80%' }, - }, { key: 'donor_name', header: 'Donor', - width: '20%', + width: '30%', cell: (donation) => donation.donor_name, }, { key: 'project', header: 'Project Name', - width: '25%', + width: '30%', cell: (donation) => donation.project_name, }, { key: 'amount', header: 'Amount', - width: '15%', + width: '20%', cell: (donation) => formatCurrencyPrecise(donation.amount), skeleton: { width: '55%' }, }, From b6b96a2733295da1e59dee5734c6aa3a14903516 Mon Sep 17 00:00:00 2001 From: mehanana Date: Sat, 22 Aug 2026 21:14:17 -0400 Subject: [PATCH 4/4] small fixes to the filter button & changes to the column names --- apps/frontend/src/app/donations/page.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 7b3ee1c2..12518a67 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -38,7 +38,7 @@ export default function DonationsPage() { const [currentPage, setCurrentPage] = useState(1); const [search, setSearch] = useState(''); const [showFilter, setShowFilter] = useState(false); - const [selectedDonor, setSelectedDonor] = useState(''); + const [selectedDonors, setSelectedDonors] = useState([]); const [showSort, setShowSort] = useState(false); const [selectedSort, setSelectedSort] = useState(''); @@ -98,9 +98,9 @@ export default function DonationsPage() { ) : rows; - if (selectedDonor) { - matching = matching.filter((row) => String(row.donor_id) === selectedDonor); - } + if (selectedDonors.length > 0) { + matching = matching.filter((row) => selectedDonors.includes(String(row.donor_id))); + } if (selectedSort === 'Amount') { return [...matching].sort((a, b) => Number(b.amount) - Number(a.amount)); @@ -111,7 +111,7 @@ export default function DonationsPage() { ); } return matching; - }, [rows, search, selectedDonor, selectedSort]); + }, [rows, search, selectedDonors, selectedSort]); const totalPages = Math.max(1, Math.ceil(filtered.length / ROWS_PER_PAGE)); const page = Math.min(currentPage, totalPages); @@ -130,7 +130,7 @@ export default function DonationsPage() { }, { key: 'donor_name', - header: 'Donor', + header: 'Donor Name', width: '30%', cell: (donation) => donation.donor_name, }, @@ -247,15 +247,17 @@ export default function DonationsPage() { onClick={() => setShowFilter((prev) => !prev)} > Filter By Donor + {selectedDonors.length > 0 && ` (${selectedDonors.length})`} {showFilter && (
{ - setSelectedDonor(val as string); + setSelectedDonors(val as string[]); setCurrentPage(1); }} />