diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index bee4cb4..bbfc619 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -23,6 +23,37 @@ export type DonationStatsResponse = { monthToDate: number; }; +export type DonationListRow = { + id: number; + firstName: string; + lastName: string; + email: string; + amount: number; + donationType: 'one_time' | 'recurring'; + recurringInterval?: + | 'weekly' + | 'monthly' + | 'yearly' + | 'bimonthly' + | 'quarterly' + | 'annually'; + dedicationMessage?: string; + showDedicationPublicly: boolean; + status: 'pending' | 'succeeded' | 'failed' | 'cancelled'; + createdAt: string; + updatedAt: string; + transactionId?: string; + isAnonymous: boolean; +}; + +export type DonationListResponse = { + rows: DonationListRow[]; + total: number; + page: number; + perPage: number; + totalPages: number; +}; + export type ActiveGoalResponse = { goal: { id: number; @@ -222,22 +253,7 @@ export class ApiClient { status?: 'pending' | 'succeeded' | 'failed' | 'cancelled'; startDate?: string; endDate?: string; - }): Promise<{ - rows: Array<{ - id: number; - firstName: string; - lastName: string; - email: string; - amount: number; - donationType: 'one_time' | 'recurring'; - status: string; - createdAt: string; - }>; - total: number; - page: number; - perPage: number; - totalPages: number; - }> { + }): Promise { try { const res = await this.axiosInstance.get('/api/donations', { params, @@ -257,6 +273,17 @@ export class ApiClient { } } + public async exportDonationsCsv(): Promise { + try { + const res = await this.axiosInstance.get('/api/donations/export', { + responseType: 'blob', + }); + return res.data as Blob; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to export donations'); + } + } + public async updateUserStatus( id: number, status: 'ADMIN' | 'STANDARD', diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index d9b13f9..4f10b20 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -16,6 +16,7 @@ import { ConfirmRegisteredPage } from '@containers/auth/ConfirmRegisteredPage'; import { DashboardPage } from '@containers/dashboard/DashboardPage'; import { DonorStatsChart } from '@components/DonorStatsChart'; import DashboardOverview from '@containers/dashboard/sidebar/DashboardOverview'; +import DonationTrackerPage from '@containers/dashboard/donations/DonationTrackerPage'; import { EmailEditor } from './components/EmailComms/EmailEditorOverviewPage'; import { AdminGrowingGoalTester } from '@containers/dashboard/AdminGrowingGoalTester'; import OverviewPage from '@containers/dashboard/OverviewPage'; @@ -49,6 +50,16 @@ const router = createBrowserRouter([ path: '', element: , }, + { + path: 'donations', + element: , + children: [ + { + path: '', + element: , + }, + ], + }, { path: 'email', element: , diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx index 21caa09..bd86421 100644 --- a/apps/frontend/src/components/ui/button.tsx +++ b/apps/frontend/src/components/ui/button.tsx @@ -42,46 +42,56 @@ const buttonVariants = cva( }, }, ); -function Button({ - className, - variant = 'default', - size = 'default', - asChild = false, - withShareIcon = false, - children, - ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean; - withShareIcon?: boolean; - }) { - const Comp = asChild ? Slot : 'button'; - return ( - - {children} - {withShareIcon && ( - - - - - - )} - - ); -} +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + withShareIcon?: boolean; + } +>( + ( + { + className, + variant = 'default', + size = 'default', + asChild = false, + withShareIcon = false, + children, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : 'button'; + return ( + + {children} + {withShareIcon && ( + + + + + + )} + + ); + }, +); +Button.displayName = 'Button'; export { Button, buttonVariants }; diff --git a/apps/frontend/src/containers/dashboard/UserManagement.tsx b/apps/frontend/src/containers/dashboard/UserManagement.tsx index baa956e..61c194f 100644 --- a/apps/frontend/src/containers/dashboard/UserManagement.tsx +++ b/apps/frontend/src/containers/dashboard/UserManagement.tsx @@ -72,6 +72,7 @@ export const UserManagement: React.FC = () => { const [denyingUser, setDenyingUser] = useState(null); const [verifyingUser, setVerifyingUser] = useState(null); const [modalPosition, setModalPosition] = useState< + // eslint-disable-next-line no-restricted-globals { top: number; right: number } | undefined >(undefined); const [isUpdatingRole, setIsUpdatingRole] = useState(false); diff --git a/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx b/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx new file mode 100644 index 0000000..dd330ef --- /dev/null +++ b/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx @@ -0,0 +1,1090 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ArrowDownUp, + CalendarDays, + Download, + Plus, + Search, + SlidersHorizontal, + X, +} from 'lucide-react'; + +import apiClient, { type DonationListRow } from '@api/apiClient'; +import { Button } from '@components/ui/button'; +import { Card, CardContent } from '@components/ui/card'; +import { Input } from '@components/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@components/ui/popover'; + +const ROWS_PER_PAGE = 14; + +type SortKey = 'newest' | 'oldest' | 'highest' | 'lowest'; + +type RecurrenceFilterKey = + | 'one_time' + | 'weekly' + | 'monthly' + | 'yearly' + | 'bimonthly' + | 'quarterly' + | 'annually'; + +type FilterState = { + recurrences: Set; + statuses: Set; + dateFrom: string; + dateTo: string; + amountMin: string; + amountMax: string; +}; + +type CreateDonationState = { + firstName: string; + lastName: string; + email: string; + amount: string; + reason: string; + donationType: 'one_time' | 'recurring'; + recurringInterval: 'weekly' | 'monthly' | 'annually'; + isAnonymous: boolean; + showDedicationPublicly: boolean; +}; + +const SORT_LABELS: Record = { + newest: 'Most Recent', + oldest: 'Oldest', + highest: 'Greatest', + lowest: 'Least', +}; + +function cn(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(amount); +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)); +} + +function getRecurrenceLabel(donation: DonationListRow): string { + if (donation.donationType !== 'recurring') { + return 'One-Time'; + } + + if (!donation.recurringInterval) { + return 'Recurring'; + } + + return ( + donation.recurringInterval.charAt(0).toUpperCase() + + donation.recurringInterval.slice(1) + ); +} + +function getReasonLabel(donation: DonationListRow): string { + if (donation.isAnonymous) { + return 'Anonymous'; + } + + if (donation.showDedicationPublicly && donation.dedicationMessage) { + return donation.dedicationMessage; + } + + return 'Standard'; +} + +function getFeeIndicator(status: DonationListRow['status']): { + label: string; + className: string; +} { + return { + label: status.charAt(0).toUpperCase() + status.slice(1), + className: 'bg-emerald-500', + }; +} + +function buildPageWindow( + page: number, + totalPages: number, +): Array { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, index) => index + 1); + } + + const pages: Array = [1]; + const leftBound = Math.max(2, page - 1); + const rightBound = Math.min(totalPages - 1, page + 1); + + if (leftBound > 2) { + pages.push('ellipsis'); + } + + for (let current = leftBound; current <= rightBound; current += 1) { + pages.push(current); + } + + if (rightBound < totalPages - 1) { + pages.push('ellipsis'); + } + + pages.push(totalPages); + return pages; +} + +function createInitialFilters(): FilterState { + return { + recurrences: new Set([ + 'one_time', + 'weekly', + 'monthly', + 'yearly', + 'bimonthly', + 'quarterly', + 'annually', + ]), + statuses: new Set(['pending', 'succeeded', 'failed', 'cancelled']), + dateFrom: '', + dateTo: '', + amountMin: '', + amountMax: '', + }; +} + +function cloneFilterState(filters: FilterState): FilterState { + return { + recurrences: new Set(filters.recurrences), + statuses: new Set(filters.statuses), + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + amountMin: filters.amountMin, + amountMax: filters.amountMax, + }; +} + +function createInitialDonationForm(): CreateDonationState { + return { + firstName: '', + lastName: '', + email: '', + amount: '', + reason: '', + donationType: 'one_time', + recurringInterval: 'monthly', + isAnonymous: false, + showDedicationPublicly: false, + }; +} + +function normalizeDonationSearch(donation: DonationListRow): string { + return [ + donation.firstName, + donation.lastName, + donation.email, + donation.amount, + donation.donationType, + donation.recurringInterval ?? '', + donation.status, + donation.dedicationMessage ?? '', + donation.transactionId ?? '', + donation.isAnonymous ? 'anonymous' : '', + ] + .join(' ') + .toLowerCase(); +} + +function isRecurringMatch( + donation: DonationListRow, + recurrences: FilterState['recurrences'], +): boolean { + if (donation.donationType !== 'recurring') { + return recurrences.has('one_time'); + } + + // Fallback for legacy recurring rows with missing interval. + if (!donation.recurringInterval) { + return true; + } + + return Boolean( + donation.recurringInterval && recurrences.has(donation.recurringInterval), + ); +} + +function toStartOfDayIso(value: string): Date { + return new Date(`${value}T00:00:00`); +} + +function toEndOfDayIso(value: string): Date { + return new Date(`${value}T23:59:59.999`); +} + +type ToolbarProps = { + totalCount: number; + visibleCount: number; + onExport: () => void; + onAddDonation: () => void; + searchValue: string; + onSearchChange: (value: string) => void; + sortKey: SortKey; + onSortChange: (value: SortKey) => void; + filters: FilterState; + onApplyFilters: (value: FilterState) => void; + onResetFilters: () => void; +}; + +function DonationTrackerToolbar({ + totalCount, + visibleCount, + onExport, + onAddDonation, + searchValue, + onSearchChange, + sortKey, + onSortChange, + filters, + onApplyFilters, + onResetFilters, +}: ToolbarProps) { + const [isSortOpen, setIsSortOpen] = useState(false); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [isExportOpen, setIsExportOpen] = useState(false); + + const activeFilterCount = + (filters.dateFrom ? 1 : 0) + + (filters.dateTo ? 1 : 0) + + (filters.amountMin ? 1 : 0) + + (filters.amountMax ? 1 : 0) + + (filters.recurrences.size !== createInitialFilters().recurrences.size + ? 1 + : 0); + + const updateFilters = (next: FilterState) => { + onApplyFilters(cloneFilterState(next)); + }; + + return ( +
+
+ + onSearchChange(event.target.value)} + className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + /> +
+ +
+ + + + + + + +

+ Sort donations +

+ {[ + { key: 'newest', label: 'Most Recent - Oldest' }, + { key: 'oldest', label: 'Oldest - Most Recent' }, + { key: 'highest', label: 'Greatest - Least' }, + { key: 'lowest', label: 'Least - Greatest' }, + ].map((option) => ( + + ))} +
+
+ + + + + + +
+

Filters

+ +
+ +
+
+ + Date range +
+
+ + updateFilters({ + ...filters, + dateFrom: event.target.value, + }) + } + /> + + updateFilters({ + ...filters, + dateTo: event.target.value, + }) + } + /> +
+
+ +
+

Recurrence

+
+ {[ + { key: 'one_time', label: 'One-Time' }, + { key: 'weekly', label: 'Weekly' }, + { key: 'monthly', label: 'Monthly' }, + { key: 'yearly', label: 'Yearly' }, + { key: 'bimonthly', label: 'Bi-monthly' }, + { key: 'quarterly', label: 'Quarterly' }, + { key: 'annually', label: 'Annually' }, + ].map((option) => { + const recurrenceKey = option.key as RecurrenceFilterKey; + const active = filters.recurrences.has(recurrenceKey); + + return ( + + ); + })} +
+
+ +
+

Amount

+
+ + updateFilters({ + ...filters, + amountMin: event.target.value, + }) + } + /> + + updateFilters({ + ...filters, + amountMax: event.target.value, + }) + } + /> +
+
+ +
+ +
+
+
+ + + + + + + + + + +
+ +
+ Showing {visibleCount} of {totalCount} donations +
+
+ ); +} + +function DonationTable({ rows }: { rows: DonationListRow[] }) { + return ( +
+
+ + + + + + + + + + + + + + + {rows.map((donation) => { + const feeIndicator = getFeeIndicator(donation.status); + + return ( + + + + + + + + + + + ); + })} + +
First NameLast NameEmailAmountRecurrenceDateFeeReason
+ {donation.firstName} + + {donation.lastName} + + {donation.email} + + {formatCurrency(donation.amount)} + + + {getRecurrenceLabel(donation)} + + + {formatDate(donation.createdAt)} + + + ✓ + + + + {getReasonLabel(donation)} + +
+
+
+ ); +} + +export default function DonationTrackerPage() { + const [allRows, setAllRows] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [sortKey, setSortKey] = useState('newest'); + const [filters, setFilters] = useState(() => + createInitialFilters(), + ); + const [showCreateDonation, setShowCreateDonation] = useState(false); + const [createDonationForm, setCreateDonationForm] = + useState(() => createInitialDonationForm()); + const [createError, setCreateError] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const loadDonations = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await apiClient.getDonations({ + page: 1, + perPage: 1000, + }); + + setAllRows(response.rows); + } catch (fetchError) { + setAllRows([]); + setError( + fetchError instanceof Error + ? fetchError.message + : 'Failed to load donations', + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadDonations(); + }, [loadDonations]); + + const filteredRows = useMemo(() => { + const query = searchValue.trim().toLowerCase(); + + const nextRows = allRows.filter((donation) => { + const matchesSearch = + query.length === 0 || normalizeDonationSearch(donation).includes(query); + + const matchesRecurrence = isRecurringMatch(donation, filters.recurrences); + const matchesStatus = filters.statuses.has(donation.status); + + const donationDate = new Date(donation.createdAt); + const matchesDateFrom = + !filters.dateFrom || donationDate >= toStartOfDayIso(filters.dateFrom); + const matchesDateTo = + !filters.dateTo || donationDate <= toEndOfDayIso(filters.dateTo); + + const matchesAmountMin = + !filters.amountMin || donation.amount >= Number(filters.amountMin); + const matchesAmountMax = + !filters.amountMax || donation.amount <= Number(filters.amountMax); + + return ( + matchesSearch && + matchesRecurrence && + matchesStatus && + matchesDateFrom && + matchesDateTo && + matchesAmountMin && + matchesAmountMax + ); + }); + + return [...nextRows].sort((left, right) => { + switch (sortKey) { + case 'oldest': + return ( + new Date(left.createdAt).getTime() - + new Date(right.createdAt).getTime() + ); + case 'highest': + return right.amount - left.amount; + case 'lowest': + return left.amount - right.amount; + case 'newest': + default: + return ( + new Date(right.createdAt).getTime() - + new Date(left.createdAt).getTime() + ); + } + }); + }, [ + allRows, + filters.amountMax, + filters.amountMin, + filters.dateFrom, + filters.dateTo, + filters.recurrences, + filters.statuses, + searchValue, + sortKey, + ]); + + const totalPages = Math.max( + 1, + Math.ceil(filteredRows.length / ROWS_PER_PAGE), + ); + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages); + } + }, [page, totalPages]); + + const visibleRows = useMemo(() => { + const startIndex = (page - 1) * ROWS_PER_PAGE; + return filteredRows.slice(startIndex, startIndex + ROWS_PER_PAGE); + }, [filteredRows, page]); + + const paginationItems = useMemo( + () => buildPageWindow(page, totalPages), + [page, totalPages], + ); + + const resetFilters = () => { + setFilters(createInitialFilters()); + setPage(1); + }; + + const applyFilters = (nextFilters: FilterState) => { + setFilters(nextFilters); + setPage(1); + }; + + const refreshDonations = async () => { + await loadDonations(); + setPage(1); + }; + + const handleCreateDonation = async () => { + if (isCreating) { + return; + } + + const amount = Number(createDonationForm.amount); + if ( + !createDonationForm.firstName.trim() || + !createDonationForm.lastName.trim() || + !createDonationForm.email.trim() || + !Number.isFinite(amount) || + amount <= 0 + ) { + setCreateError('Fill out the required fields with a valid amount.'); + return; + } + + setIsCreating(true); + setCreateError(null); + + try { + await apiClient.createDonation({ + firstName: createDonationForm.firstName.trim(), + lastName: createDonationForm.lastName.trim(), + email: createDonationForm.email.trim(), + amount, + isAnonymous: createDonationForm.isAnonymous, + donationType: createDonationForm.donationType, + dedicationMessage: createDonationForm.reason.trim(), + showDedicationPublicly: createDonationForm.showDedicationPublicly, + ...(createDonationForm.donationType === 'recurring' + ? { recurringInterval: createDonationForm.recurringInterval } + : {}), + }); + + setCreateDonationForm(createInitialDonationForm()); + setShowCreateDonation(false); + await refreshDonations(); + } catch (creationError) { + setCreateError( + creationError instanceof Error + ? creationError.message + : 'Failed to create donation', + ); + } finally { + setIsCreating(false); + } + }; + + const handleExport = async () => { + const csv = await apiClient.exportDonationsCsv(); + const blobUrl = window.URL.createObjectURL(csv); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = 'donations.csv'; + link.click(); + window.URL.revokeObjectURL(blobUrl); + }; + + const startItem = visibleRows.length > 0 ? (page - 1) * ROWS_PER_PAGE + 1 : 0; + const endItem = + visibleRows.length > 0 + ? Math.min(page * ROWS_PER_PAGE, filteredRows.length) + : 0; + + return ( +
+
+ setShowCreateDonation(true)} + searchValue={searchValue} + onSearchChange={(value) => { + setSearchValue(value); + setPage(1); + }} + sortKey={sortKey} + onSortChange={(value) => { + setSortKey(value); + setPage(1); + }} + filters={filters} + onApplyFilters={applyFilters} + onResetFilters={resetFilters} + /> + + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( + + + Loading donations... + + + ) : visibleRows.length === 0 ? ( + + + No donations found for this page. + + + ) : ( + + )} + +
+

+ Showing {startItem} - {endItem} of {filteredRows.length} donations +

+ + +
+
+ + {showCreateDonation ? ( +
+
+
+

+ New Donation +

+ +
+ + {createError ? ( +
+ {createError} +
+ ) : null} + +
+ + + + + + + + + +
+ +
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx b/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx index 0edb029..c727161 100644 --- a/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx +++ b/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx @@ -4,6 +4,7 @@ import { Outlet, useLocation } from 'react-router-dom'; const ROUTE_TITLES: Record = { '/overview': 'Dashboard Overview', + '/overview/donations': 'Donation Tracker', '/overview/email': 'Email Overview', }; diff --git a/scripts/docker-dev.sh b/scripts/docker-dev.sh old mode 100644 new mode 100755 diff --git a/scripts/init-db.sql b/scripts/init-db.sql index dfdbe21..de36634 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -40,4 +40,4 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Log completion -\echo 'Database initialization completed!' +\echo 'Database initialization completed!' \ No newline at end of file