diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index d9b128d..e51690e 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -16,6 +16,7 @@ import { UseInterceptors, Req, UnauthorizedException, + BadRequestException, } from '@nestjs/common'; import { ApiTags, @@ -316,6 +317,20 @@ export class DonationsController { type: Number, description: 'maximum donation amount', }) + @ApiQuery({ + name: 'startDate', + required: false, + type: String, + description: 'filter by start date (YYYY-MM-DD format)', + example: '2026-01-01', + }) + @ApiQuery({ + name: 'endDate', + required: false, + type: String, + description: 'filter by end date (YYYY-MM-DD format)', + example: '2026-12-31', + }) @ApiResponse({ status: 200, description: 'paginated donation list', @@ -351,6 +366,8 @@ export class DonationsController { minAmount?: number, @Query('maxAmount', new ParseIntPipe({ optional: true })) maxAmount?: number, + @Query('startDate') startDateStr?: string, + @Query('endDate') endDateStr?: string, ): Promise<{ rows: DonationResponseDto[]; total: number; @@ -365,6 +382,27 @@ export class DonationsController { throw new UnauthorizedException('Admin access required'); } + let startDate: Date | undefined; + let endDate: Date | undefined; + + if (startDateStr) { + startDate = new Date(startDateStr); + if (isNaN(startDate.getTime())) { + throw new BadRequestException( + 'Invalid startDate format. Use YYYY-MM-DD', + ); + } + } + + if (endDateStr) { + endDate = new Date(endDateStr); + // Add one day to endDate to include the entire end date + endDate.setDate(endDate.getDate() + 1); + if (isNaN(endDate.getTime())) { + throw new BadRequestException('Invalid endDate format. Use YYYY-MM-DD'); + } + } + const filters: PaginationFilters = { donationType, status, @@ -372,6 +410,8 @@ export class DonationsController { recurringInterval, minAmount, maxAmount, + startDate, + endDate, }; const result = await this.donationsRepository.findPaginated( diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 1502437..36b3688 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -127,7 +127,7 @@ export class ApiClient { this.handleAxiosError(err, 'Failed to reset password'); } } - + public async getActiveGoalSummary(): Promise { try { const res = await this.axiosInstance.get('/api/donations/goal/active'); @@ -183,6 +183,8 @@ export class ApiClient { perPage?: number; donationType?: 'one_time' | 'recurring'; status?: 'pending' | 'succeeded' | 'failed' | 'cancelled'; + startDate?: string; + endDate?: string; }): Promise<{ rows: Array<{ id: number; diff --git a/apps/frontend/src/components/DonorStatsChart.tsx b/apps/frontend/src/components/DonorStatsChart.tsx index 0f621f0..4e3cb2b 100644 --- a/apps/frontend/src/components/DonorStatsChart.tsx +++ b/apps/frontend/src/components/DonorStatsChart.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Card, @@ -15,48 +15,80 @@ import { type ChartConfig, } from '@components/ui/chart'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@components/ui/dropdown-menu'; -import { Input } from '@components/ui/input'; -import { Label } from '@components/ui/label'; + Popover, + PopoverContent, + PopoverTrigger, +} from '@components/ui/popover'; import { ChevronDownIcon } from 'lucide-react'; +import { YearMonthPickerPanel, type YearMonthValue } from './YearMonthPicker'; import apiClient from '@api/apiClient'; const chartConfig = { donations: { label: 'Total Donations', - color: 'hsl(160, 60%, 45%)', + color: 'rgba(42, 157, 144, 0.7)', }, - recurring_donors: { - label: 'Recurring Donors', - color: 'hsl(220, 70%, 50%)', + recurring_donations: { + label: 'Recurring Donations', + color: 'rgb(215, 209, 117)', }, } satisfies ChartConfig; -type DataType = 'donations' | 'recurring_donors'; -type TimeUnit = 'weeks' | 'months' | 'years'; +type DataType = 'donations' | 'recurring_donations'; +type TimeframeType = 'year-to-date' | 'custom'; // Helper function to calculate date range -function getStartDate(quantity: number, unit: TimeUnit): Date { +function getStartDate( + timeframeType: TimeframeType, + customPeriod?: YearMonthValue, +): Date { const now = new Date(); - const start = new Date(now); - - switch (unit) { - case 'weeks': - start.setDate(now.getDate() - quantity * 7); - break; - case 'months': - start.setMonth(now.getMonth() - quantity); - break; - case 'years': - start.setFullYear(now.getFullYear() - quantity); - break; + + if (timeframeType === 'year-to-date') { + // Start from Jan 1 of current year + return new Date(now.getFullYear(), 0, 1); + } + + // Custom period + if (!customPeriod) { + // If no custom period yet, use today + return now; } - return start; + if (customPeriod.month === null) { + // Year only - start from Jan 1 of that year + return new Date(customPeriod.year, 0, 1); + } + + // Year and month - start from 1st of that month (1-indexed) + return new Date(customPeriod.year, customPeriod.month - 1, 1); +} + +// Helper function to calculate end date +function getEndDate( + timeframeType: TimeframeType, + customPeriod?: YearMonthValue, +): Date { + const now = new Date(); + + if (timeframeType === 'year-to-date') { + // End is today + return now; + } + + // Custom period + if (!customPeriod) { + // If no custom period yet, use today + return now; + } + + if (customPeriod.month === null) { + // Year only - end at Jan 1 of next year + return new Date(customPeriod.year + 1, 0, 1); + } + + // Year and month - end at 1st of next month + return new Date(customPeriod.year, customPeriod.month, 1); } // Helper function to format date as YYYY-MM-DD in local time @@ -82,16 +114,13 @@ function parseDateKey(value: string): Date { // Process donations data into time-series function processDonationsData( donations: Array<{ amount: number; createdAt: string; status: string }>, - startDate: Date, ): Array<{ date: string; value: number }> { const dataMap = new Map(); donations.forEach((donation) => { const donationDate = parseBackendDate(donation.createdAt); - if (donationDate >= startDate && donation.status === 'succeeded') { - const dateKey = formatDate(donationDate); - dataMap.set(dateKey, (dataMap.get(dateKey) || 0) + donation.amount); - } + const dateKey = formatDate(donationDate); + dataMap.set(dateKey, (dataMap.get(dateKey) || 0) + donation.amount); }); return Array.from(dataMap.entries()) @@ -99,40 +128,22 @@ function processDonationsData( .sort((a, b) => a.date.localeCompare(b.date)); } -// Process recurring donors data (first recurring donation per email) -function processRecurringDonorsData( +// Process recurring donations data (recurring donations only) +function processRecurringDonationsData( donations: Array<{ - email: string; + amount: number; createdAt: string; donationType: string; status: string; }>, - startDate: Date, ): Array<{ date: string; value: number }> { - // Find first recurring donation per email - const firstRecurringByEmail = new Map(); + const dataMap = new Map(); donations.forEach((donation) => { - if ( - donation.donationType === 'recurring' && - donation.status === 'succeeded' - ) { + if (donation.donationType === 'recurring') { const donationDate = parseBackendDate(donation.createdAt); - const existing = firstRecurringByEmail.get(donation.email); - - if (!existing || donationDate < existing) { - firstRecurringByEmail.set(donation.email, donationDate); - } - } - }); - - // Group by date - const dataMap = new Map(); - - firstRecurringByEmail.forEach((firstDate) => { - if (firstDate >= startDate) { - const dateKey = formatDate(firstDate); - dataMap.set(dateKey, (dataMap.get(dateKey) || 0) + 1); + const dateKey = formatDate(donationDate); + dataMap.set(dateKey, (dataMap.get(dateKey) || 0) + donation.amount); } }); @@ -143,122 +154,116 @@ function processRecurringDonorsData( export function DonorStatsChart() { const [activeChart, setActiveChart] = React.useState('donations'); - const [quantity, setQuantity] = React.useState(6); - const [unit, setUnit] = React.useState('months'); + const [timeframeType, setTimeframeType] = + React.useState('year-to-date'); + const [customPeriod, setCustomPeriod] = React.useState(); const [chartData, setChartData] = React.useState< Array<{ date: string; value: number }> >([]); const [donationsData, setDonationsData] = React.useState< Array<{ date: string; value: number }> >([]); - const [recurringDonorsData, setRecurringDonorsData] = React.useState< + const [recurringDonationsData, setRecurringDonationsData] = React.useState< Array<{ date: string; value: number }> >([]); - const [isLoading, setIsLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [customPickerOpen, setCustomPickerOpen] = React.useState(false); - // Fetch data whenever quantity or unit changes + // Fetch data whenever timeframeType or customPeriod changes React.useEffect(() => { const fetchData = async () => { - setIsLoading(true); setError(null); try { - // Fetch donations with a large perPage to get all we need + const startDate = getStartDate(timeframeType, customPeriod); + const endDate = getEndDate(timeframeType, customPeriod); + + // Format dates as YYYY-MM-DD for API + const startDateStr = formatDate(startDate); + const endDateStr = formatDate(endDate); + + // Fetch donations with date range filter const response = await apiClient.getDonations({ perPage: 10000, status: 'succeeded', + startDate: startDateStr, + endDate: endDateStr, }); - const startDate = getStartDate(quantity, unit); - const donationsProcessed = processDonationsData( + const donationsProcessed = processDonationsData(response.rows); + const recurringDonationsProcessed = processRecurringDonationsData( response.rows, - startDate, - ); - const recurringDonorsProcessed = processRecurringDonorsData( - response.rows, - startDate, ); setDonationsData(donationsProcessed); - setRecurringDonorsData(recurringDonorsProcessed); + setRecurringDonationsData(recurringDonationsProcessed); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load data'); setChartData([]); - } finally { - setIsLoading(false); } }; fetchData(); - }, [quantity, unit]); + }, [timeframeType, customPeriod]); // Update chart data when activeChart changes React.useEffect(() => { if (activeChart === 'donations') { setChartData(donationsData); } else { - setChartData(recurringDonorsData); + setChartData(recurringDonationsData); } - }, [activeChart, donationsData, recurringDonorsData]); - - // Calculate totals for display - const donationsTotal = React.useMemo(() => { - return donationsData.reduce((acc, curr) => acc + curr.value, 0); - }, [donationsData]); - - const recurringDonorsTotal = React.useMemo(() => { - return recurringDonorsData.reduce((acc, curr) => acc + curr.value, 0); - }, [recurringDonorsData]); - - const handleQuantityChange = (e: React.ChangeEvent) => { - const value = parseInt(e.target.value); - if (value >= 1 && value <= 12) { - setQuantity(value); - } - }; + }, [activeChart, donationsData, recurringDonationsData]); return ( - - -
- Donor Statistics + + +
+ + Donation Overview + - {activeChart === 'donations' - ? 'Total donation amounts over time' - : 'New recurring donors over time'} + Showing total and recurring donations.
-
- {(['donations', 'recurring_donors'] as const).map((key) => { - const chart = key as DataType; - return ( + +
+
- ); - })} -
+ + + +
+
+ + { + setCustomPeriod(v); + setTimeframeType('custom'); + setCustomPickerOpen(false); + }} + /> + +
- { - if (activeChart === 'donations') { - // Format as currency (e.g., $50) - return `$${(value / 100).toLocaleString()}`; - } else { - // Format as count (e.g., 4) - return value.toString(); - } + // Format as currency for both donations and recurring donations + return `$${(value / 100).toLocaleString()}`; }} /> { return parseDateKey(value).toLocaleDateString('en-US', { @@ -310,77 +310,52 @@ export function DonorStatsChart() { }); }} formatter={(value: any) => { - if (activeChart === 'donations') { - // Format as currency with cents - return `$${(value / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - } else { - // Format as count - return value.toLocaleString(); - } + // Format as currency for both donations and recurring donations + return `$${(value / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }} /> } /> - - + +
+
+ {(['donations', 'recurring_donations'] as const).map( + (key, index) => { + const chart = key as DataType; + return ( + + ); + }, + )} +
+
+ {error && (
{error}
)} - -
-
- -
- - - - - - - setUnit('weeks')}> - Weeks - - setUnit('months')}> - Months - - setUnit('years')}> - Years - - - -
-
-
- Showing last {quantity} {unit} -
-
); diff --git a/apps/frontend/src/components/YearMonthPicker.tsx b/apps/frontend/src/components/YearMonthPicker.tsx new file mode 100644 index 0000000..1ab684f --- /dev/null +++ b/apps/frontend/src/components/YearMonthPicker.tsx @@ -0,0 +1,250 @@ +import * as React from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { Button } from '@components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@components/ui/popover'; +import { cn } from '@lib/utils'; + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export type YearMonthValue = + | { year: number; month: null } + | { year: number; month: number }; // month is 1-indexed + +interface YearMonthPickerProps { + value?: YearMonthValue; + onChange?: (value: YearMonthValue) => void; + placeholder?: string; +} + +function formatValue(value?: YearMonthValue): string { + if (!value) return ''; + if (value.month === null) return String(value.year); + return `${MONTHS[value.month - 1]} ${value.year}`; +} + +export function YearMonthPicker({ + value, + onChange, + placeholder = 'Select period', +}: YearMonthPickerProps) { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + { + onChange?.(v); + setOpen(false); + }} + /> + + + ); +} + +interface YearMonthPickerPanelProps { + value?: YearMonthValue; + onChange?: (value: YearMonthValue) => void; +} + +export function YearMonthPickerPanel({ + value, + onChange, +}: YearMonthPickerPanelProps) { + const now = new Date(); + const curYear = now.getFullYear(); + const curMonth = now.getMonth(); // 0-indexed + + const [view, setView] = React.useState<'year' | 'month'>('year'); + const [rangeStart, setRangeStart] = React.useState(() => curYear - 11); + const [selectedYear, setSelectedYear] = React.useState( + value?.year ?? null, + ); + const [selectedMonth, setSelectedMonth] = React.useState( + value?.month != null ? value.month - 1 : null, // store 0-indexed internally + ); + + const years = Array.from({ length: 12 }, (_, i) => rangeStart + i); + const canNextRange = rangeStart + 12 <= curYear; + + function handleApply() { + if (selectedYear === null) return; + if (view === 'year') { + onChange?.({ year: selectedYear, month: null }); + } else { + if (selectedMonth === null) return; + onChange?.({ year: selectedYear, month: selectedMonth + 1 }); + } + } + + function handlePrev() { + if (view === 'year') { + setRangeStart((r) => r - 12); + } else { + setSelectedYear((y) => (y !== null ? y - 1 : y)); + setSelectedMonth(null); + } + } + + function handleNext() { + if (view === 'year') { + if (canNextRange) setRangeStart((r) => r + 12); + } else { + if (selectedYear !== null && selectedYear < curYear) { + setSelectedYear(selectedYear + 1); + setSelectedMonth(null); + } + } + } + + const canApply = + selectedYear !== null && (view === 'year' || selectedMonth !== null); + + const headerLabel = + view === 'year' + ? `${years[0]}–${years[years.length - 1]}` + : String(selectedYear); + + const nextDisabled = + view === 'year' + ? !canNextRange + : selectedYear === null || selectedYear >= curYear; + + return ( +
+
+ + {headerLabel} + +
+ + {view === 'year' ? ( +
+ {years.map((y) => { + const isFuture = y > curYear; + const isSelected = y === selectedYear; + return ( + + ); + })} +
+ ) : ( +
+ {MONTHS.map((m, i) => { + const isFuture = selectedYear === curYear && i > curMonth; + const isSelected = i === selectedMonth; + return ( + + ); + })} +
+ )} + +
+ {view === 'year' && ( + + )} + +
+
+ ); +} diff --git a/apps/frontend/src/components/ui/popover.tsx b/apps/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..08ac285 --- /dev/null +++ b/apps/frontend/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/package.json b/package.json index 62b3e08..27a2697 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@nestjs/swagger": "^7.1.12", "@nestjs/typeorm": "^10.0.0", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", diff --git a/yarn.lock b/yarn.lock index 28746f0..5ccc334 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4473,6 +4473,27 @@ aria-hidden "^1.2.4" react-remove-scroll "^2.6.3" +"@radix-ui/react-popover@^1.1.15": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz#9c852f93990a687ebdc949b2c3de1f37cdc4c5d5" + integrity sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.11" + "@radix-ui/react-focus-guards" "1.1.3" + "@radix-ui/react-focus-scope" "1.1.7" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.8" + "@radix-ui/react-portal" "1.1.9" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-use-controllable-state" "1.2.2" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + "@radix-ui/react-popper@1.2.8": version "1.2.8" resolved "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz"