Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add event product and daily sales reports - WIP #285

Open
wants to merge 3 commits into
base: v1.0.0-alpha
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/app/DomainObjects/Enums/ReportTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum ReportTypes: string
{
use BaseEnum;

case PRODUCT_SALES = 'product_sales';
case DAILY_SALES_REPORT = 'daily_sales_report';
}
61 changes: 61 additions & 0 deletions backend/app/Http/Actions/Reports/GetReportAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace HiEvents\Http\Actions\Reports;

use HiEvents\DomainObjects\Enums\ReportTypes;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Report\GetReportRequest;
use HiEvents\Services\Handlers\Reports\DTO\GetReportDTO;
use HiEvents\Services\Handlers\Reports\GetReportHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class GetReportAction extends BaseAction
{
public function __construct(private readonly GetReportHandler $reportHandler)
{
}

/**
* @throws ValidationException
*/
public function __invoke(GetReportRequest $request, int $eventId, string $reportType): JsonResponse
{
$this->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 > 366) {
throw ValidationException::withMessages(['start_date' => 'Date range must be less than 365 days.']);
}
}
}
16 changes: 16 additions & 0 deletions backend/app/Http/Request/Report/GetReportRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace HiEvents\Http\Request\Report;

use HiEvents\Http\Request\BaseRequest;

class GetReportRequest extends BaseRequest
{
public function rules(): array
{
return [
'start_date' => 'date|before:end_date|required_with:end_date|nullable',
'end_date' => 'date|after:start_date|required_with:start_date|nullable',
];
}
}
49 changes: 49 additions & 0 deletions backend/app/Services/Domain/Report/AbstractReportService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace HiEvents\Services\Domain\Report;

use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use Illuminate\Cache\Repository;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

abstract class AbstractReportService
{
public function __construct(
private readonly Repository $cache,
private readonly DatabaseManager $queryBuilder,
private readonly EventRepositoryInterface $eventRepository,
)
{
}

public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon $endDate = null): Collection
{
$event = $this->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()}";
}
}
10 changes: 10 additions & 0 deletions backend/app/Services/Domain/Report/Exception/InvalidDateRange.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace HiEvents\Services\Domain\Report\Exception;

use Exception;

class InvalidDateRange extends Exception
{

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace HiEvents\Services\Domain\Report\Factory;

use HiEvents\DomainObjects\Enums\ReportTypes;
use HiEvents\Services\Domain\Report\AbstractReportService;
use HiEvents\Services\Domain\Report\Reports\DailySalesReport;
use HiEvents\Services\Domain\Report\Reports\ProductSalesReport;
use Illuminate\Support\Facades\App;

class ReportServiceFactory
{
public function create(ReportTypes $reportType): AbstractReportService
{
return match ($reportType) {
ReportTypes::PRODUCT_SALES => App::make(ProductSalesReport::class),
ReportTypes::DAILY_SALES_REPORT => App::make(DailySalesReport::class),
};
}
}
37 changes: 37 additions & 0 deletions backend/app/Services/Domain/Report/Reports/DailySalesReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace HiEvents\Services\Domain\Report\Reports;

use HiEvents\Services\Domain\Report\AbstractReportService;
use Illuminate\Support\Carbon;

class DailySalesReport extends AbstractReportService
{
public function getSqlQuery(Carbon $startDate, Carbon $endDate): string
{
$startDateStr = $startDate->toDateString();
$endDateStr = $endDate->toDateString();

return <<<SQL
WITH date_range AS (
SELECT generate_series('$startDateStr'::date, '$endDateStr'::date, '1 day'::interval) AS date
)
SELECT
d.date,
COALESCE(eds.sales_total_gross, 0.00) AS sales_total_gross,
COALESCE(eds.total_tax, 0.00) AS total_tax,
COALESCE(eds.sales_total_before_additions, 0.00) AS sales_total_before_additions,
COALESCE(eds.products_sold, 0) AS products_sold,
COALESCE(eds.orders_created, 0) AS orders_created,
COALESCE(eds.total_fee, 0.00) AS total_fee,
COALESCE(eds.total_refunded, 0.00) AS total_refunded,
COALESCE(eds.total_views, 0) AS total_views
FROM
date_range d
LEFT JOIN event_daily_statistics eds
ON d.date = eds.date
AND eds.event_id = :event_id
ORDER BY d.date desc;
SQL;
}
}
46 changes: 46 additions & 0 deletions backend/app/Services/Domain/Report/Reports/ProductSalesReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace HiEvents\Services\Domain\Report\Reports;

use HiEvents\Services\Domain\Report\AbstractReportService;
use Illuminate\Support\Carbon;

class ProductSalesReport extends AbstractReportService
{
protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string
{
$startDateString = $startDate->format('Y-m-d H:i:s');
$endDateString = $endDate->format('Y-m-d H:i:s');

return <<<SQL
WITH filtered_orders AS (
SELECT
oi.product_id,
oi.total_tax,
oi.total_gross,
oi.total_service_fee,
oi.id AS order_item_id
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'COMPLETED'
AND o.event_id = :event_id
AND o.created_at BETWEEN '$startDateString' AND '$endDateString'
AND oi.deleted_at IS NULL
)
SELECT
p.id AS product_id,
p.title AS product_title,
p.type AS product_type,
COALESCE(SUM(fo.total_tax), 0) AS total_tax,
COALESCE(SUM(fo.total_gross), 0) AS total_gross,
COALESCE(SUM(fo.total_service_fee), 0) AS total_service_fees,
COUNT(fo.order_item_id) AS number_sold
FROM products p
LEFT JOIN filtered_orders fo ON fo.product_id = p.id
WHERE p.event_id = :event_id
AND p.deleted_at IS NULL
GROUP BY p.id, p.title, p.type
ORDER BY p."order"
SQL;
}
}
18 changes: 18 additions & 0 deletions backend/app/Services/Handlers/Reports/DTO/GetReportDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace HiEvents\Services\Handlers\Reports\DTO;

use HiEvents\DataTransferObjects\BaseDTO;
use HiEvents\DomainObjects\Enums\ReportTypes;

class GetReportDTO extends BaseDTO
{
public function __construct(
public readonly int $eventId,
public readonly ReportTypes $reportType,
public readonly ?string $startDate,
public readonly ?string $endDate
)
{
}
}
28 changes: 28 additions & 0 deletions backend/app/Services/Handlers/Reports/GetReportHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace HiEvents\Services\Handlers\Reports;

use HiEvents\Services\Domain\Report\Factory\ReportServiceFactory;
use HiEvents\Services\Handlers\Reports\DTO\GetReportDTO;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

class GetReportHandler
{
public function __construct(
private readonly ReportServiceFactory $reportServiceFactory,
)
{
}

public function handle(GetReportDTO $reportData): Collection
{
return $this->reportServiceFactory
->create($reportData->reportType)
->generateReport(
eventId: $reportData->eventId,
startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null,
endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null,
);
}
}
3 changes: 3 additions & 0 deletions backend/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
);

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api/event.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GenericDataResponse<any>>('events/' + eventId + '/reports/' + reportType + '?start_date=' + startDate + '&end_date=' + endDate);
return response.data;
}
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/AttendeeTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {t, Trans} from "@lingui/macro";
import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx";
import {useResendAttendeeProduct} from "../../../mutations/useResendAttendeeProduct.ts";
import {ViewAttendeeModal} from "../../modals/ViewAttendeeModal";
import {ActionMenu} from '../ActionMenu/index.tsx';
import {ActionMenu} from '../ActionMenu';

interface AttendeeTableProps {
attendees: Attendee[];
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/components/common/DownloadCsvButton/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ButtonProps, 'onClick'> {
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 (
<Button
leftSection={<IconDownload size={16}/>}
onClick={handleDownloadCSV}
variant="light"
{...buttonProps}
>
{t`Download CSV`}
</Button>
);
};
Loading
Loading