diff --git a/backend/app/DomainObjects/Enums/ReportTypes.php b/backend/app/DomainObjects/Enums/ReportTypes.php new file mode 100644 index 00000000..6f11acf5 --- /dev/null +++ b/backend/app/DomainObjects/Enums/ReportTypes.php @@ -0,0 +1,12 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->validateDateRange($request); + + if (!in_array($reportType, ReportTypes::valuesArray(), true)) { + throw new BadRequestHttpException('Invalid report type.'); + } + + $reportData = $this->reportHandler->handle( + reportData: new GetReportDTO( + eventId: $eventId, + reportType: ReportTypes::from($reportType), + startDate: $request->validated('start_date'), + endDate: $request->validated('end_date'), + ), + ); + + return $this->jsonResponse($reportData); + } + + /** + * @throws ValidationException + */ + private function validateDateRange(GetReportRequest $request): void + { + $startDate = $request->validated('start_date'); + $endDate = $request->validated('end_date'); + + $diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)); + + if ($diffInDays > 370) { + throw ValidationException::withMessages(['start_date' => 'Date range must be less than 370 days.']); + } + } +} diff --git a/backend/app/Http/Request/Report/GetReportRequest.php b/backend/app/Http/Request/Report/GetReportRequest.php new file mode 100644 index 00000000..458a9861 --- /dev/null +++ b/backend/app/Http/Request/Report/GetReportRequest.php @@ -0,0 +1,16 @@ + 'date|before:end_date|required_with:end_date|nullable', + 'end_date' => 'date|after:start_date|required_with:start_date|nullable', + ]; + } +} diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php index dd885fb8..bfa087a5 100644 --- a/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php +++ b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php @@ -3,7 +3,7 @@ namespace HiEvents\Resources\ProductCategory; use HiEvents\DomainObjects\ProductCategoryDomainObject; -use HiEvents\Resources\Product\ProductResource; +use HiEvents\Resources\Product\ProductResourcePublic; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -21,7 +21,7 @@ public function toArray($request): array 'order' => $this->getOrder(), 'no_products_message' => $this->getNoProductsMessage(), $this->mergeWhen((bool)$this->getProducts(), fn() => [ - 'products' => ProductResource::collection($this->getProducts()), + 'products' => ProductResourcePublic::collection($this->getProducts()), ]), ]; } diff --git a/backend/app/Services/Domain/Report/AbstractReportService.php b/backend/app/Services/Domain/Report/AbstractReportService.php new file mode 100644 index 00000000..24c3058a --- /dev/null +++ b/backend/app/Services/Domain/Report/AbstractReportService.php @@ -0,0 +1,49 @@ +eventRepository->findById($eventId); + $timezone = $event->getTimezone(); + + $endDate = Carbon::parse($endDate ?? now(), $timezone); + $startDate = Carbon::parse($startDate ?? $endDate->copy()->subDays(30), $timezone); + + $reportResults = $this->cache->remember( + key: $this->getCacheKey($eventId, $startDate, $endDate), + ttl: Carbon::now()->addSeconds(20), + callback: fn() => $this->queryBuilder->select( + $this->getSqlQuery($startDate, $endDate), + [ + 'event_id' => $eventId, + ] + ) + ); + + return collect($reportResults); + } + + abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string; + + protected function getCacheKey(int $eventId, ?Carbon $startDate, ?Carbon $endDate): string + { + return static::class . "$eventId.{$startDate?->toDateString()}.{$endDate?->toDateString()}"; + } +} diff --git a/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php b/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php new file mode 100644 index 00000000..56f5e6ba --- /dev/null +++ b/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php @@ -0,0 +1,10 @@ + App::make(ProductSalesReport::class), + ReportTypes::DAILY_SALES_REPORT => App::make(DailySalesReport::class), + ReportTypes::PROMO_CODES_REPORT => App::make(PromoCodesReport::class), + }; + } +} diff --git a/backend/app/Services/Domain/Report/Reports/DailySalesReport.php b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php new file mode 100644 index 00000000..ba396f37 --- /dev/null +++ b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php @@ -0,0 +1,37 @@ +toDateString(); + $endDateStr = $endDate->toDateString(); + + return <<format('Y-m-d H:i:s'); + $endDateString = $endDate->format('Y-m-d H:i:s'); + + return <<format('Y-m-d H:i:s'); + $endDateString = $endDate->format('Y-m-d H:i:s'); + + return << 0 THEN ROUND(AVG(o.total_before_additions - o.total_gross)::numeric, 2) + ELSE 0 + END as avg_discount_per_order, + CASE + WHEN COUNT(o.id) > 0 THEN ROUND(AVG(o.total_gross)::numeric, 2) + ELSE 0 + END as avg_order_value, + MIN(o.created_at AT TIME ZONE 'UTC') as first_used_at, + MAX(o.created_at AT TIME ZONE 'UTC') as last_used_at, + pc.discount as configured_discount, + pc.discount_type, + pc.max_allowed_usages, + pc.expiry_date AT TIME ZONE 'UTC' as expiry_date, + CASE + WHEN pc.max_allowed_usages IS NOT NULL + THEN pc.max_allowed_usages - COUNT(o.id)::integer + END as remaining_uses, + CASE + WHEN pc.expiry_date < CURRENT_TIMESTAMP THEN 'Expired' + WHEN pc.max_allowed_usages IS NOT NULL AND COUNT(o.id) >= pc.max_allowed_usages THEN 'Limit Reached' + WHEN pc.deleted_at IS NOT NULL THEN 'Deleted' + ELSE 'Active' + END as status + FROM promo_codes pc + LEFT JOIN orders o ON + pc.id = o.promo_code_id + AND o.deleted_at IS NULL + AND o.status NOT IN ('RESERVED') + AND o.event_id = :event_id + AND o.created_at >= '$startDateString' + AND o.created_at <= '$endDateString' + WHERE + pc.deleted_at IS NULL + AND pc.event_id = :event_id + GROUP BY + pc.id, + COALESCE(pc.code, o.promo_code), + pc.discount, + pc.discount_type, + pc.max_allowed_usages, + pc.expiry_date, + pc.deleted_at + ) + SELECT + promo_code, + times_used, + unique_customers, + configured_discount, + discount_type, + total_gross_sales, + total_before_discounts, + total_discount_amount, + avg_discount_per_order, + avg_order_value, + first_used_at, + last_used_at, + max_allowed_usages, + remaining_uses, + expiry_date, + status + FROM promo_metrics + ORDER BY + total_gross_sales DESC, + promo_code; + SQL; + } +} diff --git a/backend/app/Services/Handlers/Reports/DTO/GetReportDTO.php b/backend/app/Services/Handlers/Reports/DTO/GetReportDTO.php new file mode 100644 index 00000000..024dab42 --- /dev/null +++ b/backend/app/Services/Handlers/Reports/DTO/GetReportDTO.php @@ -0,0 +1,18 @@ +reportServiceFactory + ->create($reportData->reportType) + ->generateReport( + eventId: $reportData->eventId, + startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, + endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + ); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 92dd90eb..aec0d8e8 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -88,6 +88,7 @@ use HiEvents\Http\Actions\Questions\GetQuestionsAction; use HiEvents\Http\Actions\Questions\GetQuestionsPublicAction; use HiEvents\Http\Actions\Questions\SortQuestionsAction; +use HiEvents\Http\Actions\Reports\GetReportAction; use HiEvents\Http\Actions\TaxesAndFees\CreateTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\DeleteTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\EditTaxOrFeeAction; @@ -245,6 +246,8 @@ function (Router $router): void { $router->get('/events/{event_id}/check-in-lists/{check_in_list_id}', GetCheckInListAction::class); $router->put('/events/{event_id}/check-in-lists/{check_in_list_id}', UpdateCheckInListAction::class); $router->delete('/events/{event_id}/check-in-lists/{check_in_list_id}', DeleteCheckInListAction::class); + + $router->get('/events/{event_id}/reports/{report_type}', GetReportAction::class); } ); diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index 41ad35a2..4d7ad46c 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -82,6 +82,11 @@ export const eventsClient = { status }); return response.data; + }, + + getEventReport: async (eventId: IdParam, reportType: IdParam, startDate?: string, endDate?: string) => { + const response = await api.get>('events/' + eventId + '/reports/' + reportType + '?start_date=' + startDate + '&end_date=' + endDate); + return response.data; } } diff --git a/frontend/src/components/common/DownloadCsvButton/index.tsx b/frontend/src/components/common/DownloadCsvButton/index.tsx new file mode 100644 index 00000000..dcef9bed --- /dev/null +++ b/frontend/src/components/common/DownloadCsvButton/index.tsx @@ -0,0 +1,51 @@ +import {Button, ButtonProps} from '@mantine/core'; +import {IconDownload} from '@tabler/icons-react'; +import {t} from '@lingui/macro'; + +interface DownloadCsvButtonProps extends Omit { + headers: string[]; + data: (string | number)[][]; + filename?: string; +} + +export const DownloadCsvButton = ({ + headers, + data, + filename = 'download.csv', + ...buttonProps + }: DownloadCsvButtonProps) => { + const handleDownloadCSV = () => { + const csvData = data.map(row => + row.map(cell => + typeof cell === 'string' ? `"${cell}"` : cell + ).join(',') + ); + + const csvContent = [ + headers.join(','), + ...csvData + ].join('\n'); + + // Create and trigger download + const blob = new Blob([csvContent], {type: 'text/csv;charset=utf-8;'}); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( + + ); +}; diff --git a/frontend/src/components/common/ReportTable/index.tsx b/frontend/src/components/common/ReportTable/index.tsx new file mode 100644 index 00000000..0f361eea --- /dev/null +++ b/frontend/src/components/common/ReportTable/index.tsx @@ -0,0 +1,304 @@ +import {ComboboxItem, Group, LoadingOverlay, Paper, Select, Table as MantineTable} from '@mantine/core'; +import {t} from '@lingui/macro'; +import {DatePickerInput} from "@mantine/dates"; +import {IconArrowDown, IconArrowsSort, IconArrowUp, IconCalendar} from "@tabler/icons-react"; +import React, {useMemo, useState} from "react"; +import {PageTitle} from "../PageTitle"; +import {DownloadCsvButton} from "../DownloadCsvButton"; +import {Table, TableHead} from "../Table"; +import '@mantine/dates/styles.css'; +import {useGetEventReport} from "../../../queries/useGetEventReport.ts"; +import {useParams} from "react-router-dom"; +import {Event} from "../../../types.ts"; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +interface Column { + key: keyof T; + label: string; + render?: (value: any, row: T) => React.ReactNode; + sortable?: boolean; +} + +interface ReportProps { + title: string; + columns: Column[]; + event: Event + isLoading?: boolean; + showDateFilter?: boolean; + defaultStartDate?: Date; + defaultEndDate?: Date; + onDateRangeChange?: (range: [Date | null, Date | null]) => void; + enableDownload?: boolean; + downloadFileName?: string; + showCustomDatePicker?: boolean; +} + +const TIME_PERIODS = [ + {value: '24h', label: t`Last 24 hours`}, + {value: '48h', label: t`Last 48 hours`}, + {value: '7d', label: t`Last 7 days`}, + {value: '14d', label: t`Last 14 days`}, + {value: '30d', label: t`Last 30 days`}, + {value: '90d', label: t`Last 90 days`}, + {value: '6m', label: t`Last 6 months`}, + {value: 'ytd', label: t`Year to date`}, + {value: '12m', label: t`Last 12 months`}, + {value: 'custom', label: t`Custom Range`} +]; + +const ReportTable = >({ + title, + columns, + isLoading = false, + showDateFilter = true, + defaultStartDate = new Date(new Date().setMonth(new Date().getMonth() - 3)), + defaultEndDate = new Date(), + onDateRangeChange, + enableDownload = true, + downloadFileName = 'report.csv', + showCustomDatePicker = false, + event + }: ReportProps) => { + const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([ + dayjs(defaultStartDate).tz(event.timezone).toDate(), + dayjs(defaultEndDate).tz(event.timezone).toDate() + ]); + const [selectedPeriod, setSelectedPeriod] = useState('90d'); + const [showDatePickerInput, setShowDatePickerInput] = useState(showCustomDatePicker); + const [sortField, setSortField] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null); + const {reportType, eventId} = useParams(); + const reportQuery = useGetEventReport(eventId, reportType, dateRange[0], dateRange[1]); + const data = (reportQuery.data || []) as T[]; + + const calculateDateRange = (period: string): [Date | null, Date | null] => { + if (period === 'custom') { + setShowDatePickerInput(true); + return dateRange; + } + setShowDatePickerInput(false); + + let end = dayjs().tz(event.timezone).endOf('day'); + let start = dayjs().tz(event.timezone); + + switch (period) { + case '24h': + start = start.startOf('day'); + end = start.endOf('day'); + break; + case '48h': + start = start.subtract(1, 'day').startOf('day'); + end = start.endOf('day').add(1, 'day'); + break; + case '7d': + start = start.subtract(6, 'day').startOf('day'); + break; + case '14d': + start = start.subtract(13, 'day').startOf('day'); + break; + case '30d': + start = start.subtract(29, 'day').startOf('day'); + break; + case '90d': + start = start.subtract(89, 'day').startOf('day'); + break; + case '6m': + start = start.subtract(6, 'month').startOf('day'); + break; + case 'ytd': + start = start.startOf('year'); + break; + case '12m': + start = start.subtract(12, 'month').startOf('day'); + break; + default: + return [null, null]; + } + + return [start.toDate(), end.toDate()]; + }; + + const handlePeriodChange = (value: string | null, _: ComboboxItem) => { + if (!value) return; + setSelectedPeriod(value); + const newRange = calculateDateRange(value); + setDateRange(newRange); + onDateRangeChange?.(newRange); + }; + + const handleDateRangeChange = (newRange: [Date | null, Date | null]) => { + const [start, end] = newRange; + const tzStart = start ? dayjs(start).tz(event.timezone) : null; + const tzEnd = end ? dayjs(end).tz(event.timezone) : null; + + const tzRange: [Date | null, Date | null] = [ + tzStart?.toDate() || null, + tzEnd?.toDate() || null + ]; + + setDateRange(tzRange); + onDateRangeChange?.(tzRange); + }; + + if (isLoading) { + return ( + + + + ); + } + + const handleSort = (field: keyof T) => { + if (sortField === field) { + if (sortDirection === 'asc') setSortDirection('desc'); + else if (sortDirection === 'desc') { + setSortDirection(null); + setSortField(null); + } else setSortDirection('asc'); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; + + const getSortIcon = (field: keyof T) => { + if (sortField !== field) return ; + if (sortDirection === 'asc') return ; + if (sortDirection === 'desc') return ; + return ; + }; + + const sortedData = useMemo(() => { + return [...data].sort((a, b) => { + if (!sortField || !sortDirection) return 0; + const aValue = a[sortField]; + const bValue = b[sortField]; + + const aNum = Number(aValue); + const bNum = Number(bValue); + + if (!isNaN(aNum) && !isNaN(bNum)) { + return sortDirection === 'asc' ? aNum - bNum : bNum - aNum; + } + + if (typeof aValue === 'string' && typeof bValue === 'string') { + return sortDirection === 'asc' + ? aValue.toLowerCase().localeCompare(bValue.toLowerCase()) + : bValue.toLowerCase().localeCompare(aValue.toLowerCase()); + } + + return 0; + }); + }, [data, sortField, sortDirection]); + + const csvHeaders = columns.map(col => col.label); + const csvData = sortedData.map(row => + columns.map(col => { + const value = row[col.key]; + return typeof value === 'number' ? value.toString() : value; + }) + ); + + const loadingMessage = () => { + const wrapper = (message: React.ReactNode) => ( + + + {message} + + + ); + + if (reportQuery.isLoading) { + return wrapper(t`Loading...`); + } + + if (showDateFilter && (!dateRange[0] || !dateRange[1])) { + return wrapper(t`No data to show. Please select a date range`); + } + + if (!showDateFilter && dateRange[0] && dateRange[1]) { + return wrapper(t`No data available`); + } + }; + + return ( + <> + + {t`${title}`} + + {showDateFilter && ( +