From 60edf2283c5062e40b77b4bb4a326e541ac64bc9 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Mon, 18 May 2026 10:42:02 +0200 Subject: [PATCH 1/3] Feat: redesign dashboards --- CLAUDE.md | 10 +- backend/VERSION | 0 .../Commands/SeedDevDashboardDataCommand.php | 262 +++++++ .../Events/Stats/GetEventStatsAction.php | 44 +- .../Stats/GetOrganizerStatsAction.php | 46 +- .../OrganizerDailyStatsResponseDTO.php | 15 + .../Organizer/OrganizerStatsResponseDTO.php | 16 +- .../Eloquent/OrganizerRepository.php | 170 ++++- .../OrganizerRepositoryInterface.php | 8 +- .../DTO/GetOrganizerStatsRequestDTO.php | 9 +- .../Organizer/GetOrganizerStatsHandler.php | 37 +- .../Domain/Event/EventStatsFetchService.php | 66 +- .../Eloquent/OrganizerRepositoryTest.php | 217 ++++++ .../Event/EventStatsFetchServiceTest.php | 6 +- frontend/src/api/event.client.ts | 7 +- frontend/src/api/organizer.client.ts | 13 +- .../common/KpiGrid/KpiGrid.module.scss | 100 +++ .../src/components/common/KpiGrid/index.tsx | 100 +++ .../PeriodSelector/PeriodSelector.module.scss | 26 + .../common/PeriodSelector/index.tsx | 103 +++ .../common/StatBoxes/StatBoxes.module.scss | 91 --- .../src/components/common/StatBoxes/index.tsx | 149 ++-- .../src/components/layouts/Event/index.tsx | 9 +- .../modals/CreateEventModal/index.tsx | 2 +- .../EventDashboard/EventDashboard.module.scss | 216 +----- .../NextOccurrenceHero.module.scss | 2 +- .../NextOccurrenceHero/index.tsx | 2 +- .../EventDashboard/SetupChecklist.module.scss | 292 +++++++ .../event/EventDashboard/SetupChecklist.tsx | 225 ++++++ .../routes/event/EventDashboard/index.tsx | 308 +++----- .../ConfettiAnimaiton/index.tsx | 130 ---- .../GettingStarted/GettingStarted.module.scss | 222 ------ .../routes/event/GettingStarted/index.tsx | 229 ------ .../Settings/Sections/MiscSettings/index.tsx | 10 +- .../OrganizerDashboard.module.scss | 458 +++++------ .../organizer/OrganizerDashboard/index.tsx | 612 ++++++++------- .../src/components/routes/welcome/index.tsx | 2 +- frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 714 ++++++++++-------- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 714 ++++++++++-------- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 714 ++++++++++-------- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 714 ++++++++++-------- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 714 ++++++++++-------- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 714 ++++++++++-------- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 714 ++++++++++-------- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 708 +++++++++-------- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 714 ++++++++++-------- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 714 ++++++++++-------- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 692 +++++++++-------- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 714 ++++++++++-------- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 714 ++++++++++-------- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 714 ++++++++++-------- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 714 ++++++++++-------- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 714 ++++++++++-------- frontend/src/mutations/useCreateProduct.ts | 8 +- frontend/src/mutations/useUpdateProduct.ts | 2 - frontend/src/queries/useGetEventStats.ts | 8 +- frontend/src/queries/useGetOrganizerStats.ts | 16 +- frontend/src/queries/useGetProducts.ts | 13 - frontend/src/router.tsx | 7 - frontend/src/styles/global.scss | 11 + frontend/src/types.ts | 12 + frontend/src/utilites/periodPreset.ts | 69 ++ 78 files changed, 9087 insertions(+), 6701 deletions(-) create mode 100755 backend/VERSION create mode 100644 backend/app/Console/Commands/SeedDevDashboardDataCommand.php create mode 100644 backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php create mode 100644 backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php create mode 100644 frontend/src/components/common/KpiGrid/KpiGrid.module.scss create mode 100644 frontend/src/components/common/KpiGrid/index.tsx create mode 100644 frontend/src/components/common/PeriodSelector/PeriodSelector.module.scss create mode 100644 frontend/src/components/common/PeriodSelector/index.tsx create mode 100644 frontend/src/components/routes/event/EventDashboard/SetupChecklist.module.scss create mode 100644 frontend/src/components/routes/event/EventDashboard/SetupChecklist.tsx delete mode 100644 frontend/src/components/routes/event/GettingStarted/ConfettiAnimaiton/index.tsx delete mode 100644 frontend/src/components/routes/event/GettingStarted/GettingStarted.module.scss delete mode 100644 frontend/src/components/routes/event/GettingStarted/index.tsx delete mode 100644 frontend/src/queries/useGetProducts.ts create mode 100644 frontend/src/utilites/periodPreset.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0b0c572958..ea74b2f07c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,13 @@ cd docker/development ## Development Guidelines +### Comments — hard rule for all code (backend, frontend, SCSS) +- **DON'T** add explanatory comments. The code must speak for itself. +- This includes "why" comments justifying a design choice ("X is intentionally omitted because…", "matches the rest of the section rhythm…", "Views live on the per-event-per-day table…", "Online events satisfy the where requirement…"). If you'd write that, rename a variable / extract a function / restructure the code instead, or just leave it implicit. +- Functional annotations are fine: PHPDoc `@throws` / `@return` / `@param`, `// TODO(handle:owner)` linked to a tracked task, schema comments inside SQL migrations that future migrations depend on. +- Never restate what the next line does. If a reviewer can read the diff and understand it, the comment is noise. +- If you're tempted to leave a comment "for the next agent", **don't** — write it as a CLAUDE.md note instead. + ### Backend #### Architecture Flow @@ -58,7 +65,7 @@ cd docker/development - **ALWAYS** wrap all translatable strings in `__()` helper - Domain Objects are auto-generated via `php artisan generate-domain-objects` - never edit manually - **Always** create unit tests for new features in `backend/tests/Unit/` -- **DON'T** add comments unless absolutely necessary +- **DON'T** add comments — see the comments rule above. No exceptions for "this seems useful context". - **ALWAYS** sanitize user-provided content with `HtmlPurifierService` before storing, especially content rendered as HTML #### DTOs @@ -93,6 +100,7 @@ cd docker/development - **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead - Unit tests extend Laravel's TestCase, not PHPUnit's TestCase - Use Mockery for mocking +- **Unit suite (`tests/Unit/`) is for pure isolation tests** — no DB, no HTTP, no real container resolution. If a test uses `DatabaseTransactions`, hits the DB (raw `DB::` calls, factories that persist, repository methods that query), or boots significant framework state, it's an integration test and belongs in `tests/Feature/` (mirror the path, e.g. `tests/Feature/Repository/Eloquent/`). Running `--testsuite=Unit` must stay fast and DB-free. - Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` - Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass. diff --git a/backend/VERSION b/backend/VERSION new file mode 100755 index 0000000000..e69de29bb2 diff --git a/backend/app/Console/Commands/SeedDevDashboardDataCommand.php b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php new file mode 100644 index 0000000000..5cfe4f460d --- /dev/null +++ b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php @@ -0,0 +1,262 @@ +environment('production') && !$this->option('force')) { + $this->error('Refusing to run in production. Pass --force to override.'); + return self::FAILURE; + } + + $eventId = (int)$this->argument('eventId'); + $days = (int)$this->option('days'); + + $event = DB::table('events')->where('id', $eventId)->first(); + if ($event === null) { + $this->error("Event {$eventId} not found."); + return self::FAILURE; + } + + $occurrence = DB::table('event_occurrences') + ->where('event_id', $eventId) + ->whereNull('deleted_at') + ->first(); + + if ($occurrence === null) { + $this->error("Event {$eventId} has no event_occurrence rows. Cannot seed daily statistics."); + return self::FAILURE; + } + + $this->info("Seeding {$days} days of dummy data for event {$eventId} ({$event->title}, {$event->currency})"); + + DB::transaction(function () use ($eventId, $days, $event, $occurrence) { + $this->cleanup($eventId); + + $today = CarbonImmutable::today(); + $aggregateGross = 0.0; + $aggregateTax = 0.0; + $aggregateFee = 0.0; + $aggregateRefunded = 0.0; + $aggregateOrders = 0; + $aggregateProducts = 0; + $aggregateAttendees = 0; + $aggregateViews = 0; + $aggregateCancelled = 0; + + $bar = $this->output->createProgressBar($days); + $bar->start(); + + for ($i = $days - 1; $i >= 0; $i--) { + $date = $today->subDays($i); + + $isWeekend = in_array($date->dayOfWeek, [0, 6], true); + $orderCount = $this->randomOrderCount($i, $isWeekend); + + $dayProducts = 0; + $dayAttendees = 0; + $dayGross = 0.0; + $dayTax = 0.0; + $dayFee = 0.0; + $dayRefunded = 0.0; + $dayOrdersCreated = 0; + $dayOrdersCancelled = 0; + $dayViews = random_int(20, 180) + ($isWeekend ? 50 : 0); + + for ($n = 0; $n < $orderCount; $n++) { + $items = random_int(1, 4); + $unitPrice = $this->randomChoice([15.00, 25.00, 35.00, 45.00, 75.00]); + $beforeAdditions = round($unitPrice * $items, 2); + $tax = round($beforeAdditions * 0.135, 2); + $fee = round($beforeAdditions * 0.025, 2); + $gross = round($beforeAdditions + $tax + $fee, 2); + + $isCancelled = random_int(1, 100) <= 8; + $refundedAmount = 0.0; + if (!$isCancelled && random_int(1, 100) <= 6) { + $refundedAmount = $gross; + } + + $createdAt = $date->setTime(random_int(8, 22), random_int(0, 59), random_int(0, 59)); + + $status = $isCancelled ? 'CANCELLED' : 'COMPLETED'; + $paymentStatus = $isCancelled ? null : 'PAYMENT_RECEIVED'; + $refundStatus = $refundedAmount > 0 ? 'REFUNDED' : null; + + DB::table('orders')->insert([ + 'short_id' => IdHelper::shortId(IdHelper::ORDER_PREFIX), + 'public_id' => IdHelper::publicId(IdHelper::ORDER_PREFIX), + 'event_id' => $eventId, + 'currency' => $event->currency, + 'first_name' => $this->randomChoice(self::FIRST_NAMES), + 'last_name' => $this->randomChoice(self::LAST_NAMES), + 'email' => 'seed' . random_int(1000, 9999) . '@example.com', + 'status' => $status, + 'payment_status' => $paymentStatus, + 'refund_status' => $refundStatus, + 'total_before_additions' => $beforeAdditions, + 'total_gross' => $gross, + 'total_tax' => $tax, + 'total_fee' => $fee, + 'total_refunded' => $refundedAmount, + 'is_manually_created' => false, + 'notes' => self::SEED_NOTE, + 'locale' => 'en', + 'payment_provider' => 'STRIPE', + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + if ($isCancelled) { + $dayOrdersCancelled++; + continue; + } + + $dayOrdersCreated++; + $dayProducts += $items; + $dayAttendees += $items; + $dayGross += $gross; + $dayTax += $tax; + $dayFee += $fee; + $dayRefunded += $refundedAmount; + } + + DB::table('event_occurrence_daily_statistics')->upsert( + [ + [ + 'event_id' => $eventId, + 'event_occurrence_id' => $occurrence->id, + 'date' => $date->toDateString(), + 'products_sold' => $dayProducts, + 'attendees_registered' => $dayAttendees, + 'sales_total_gross' => $dayGross, + 'sales_total_before_additions' => round($dayGross - $dayTax - $dayFee, 2), + 'total_tax' => $dayTax, + 'total_fee' => $dayFee, + 'orders_created' => $dayOrdersCreated, + 'orders_cancelled' => $dayOrdersCancelled, + 'total_refunded' => $dayRefunded, + 'version' => 0, + 'created_at' => $date, + 'updated_at' => $date, + ], + ], + ['event_occurrence_id', 'date'], + [ + 'products_sold', 'attendees_registered', + 'sales_total_gross', 'sales_total_before_additions', + 'total_tax', 'total_fee', + 'orders_created', 'orders_cancelled', 'total_refunded', + 'updated_at', + ], + ); + + $aggregateGross += $dayGross; + $aggregateTax += $dayTax; + $aggregateFee += $dayFee; + $aggregateRefunded += $dayRefunded; + $aggregateOrders += $dayOrdersCreated; + $aggregateProducts += $dayProducts; + $aggregateAttendees += $dayAttendees; + $aggregateViews += $dayViews; + $aggregateCancelled += $dayOrdersCancelled; + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + + DB::table('event_statistics') + ->where('event_id', $eventId) + ->update([ + 'sales_total_gross' => $aggregateGross, + 'sales_total_before_additions' => round($aggregateGross - $aggregateTax - $aggregateFee, 2), + 'total_tax' => $aggregateTax, + 'total_fee' => $aggregateFee, + 'total_refunded' => $aggregateRefunded, + 'orders_created' => $aggregateOrders, + 'orders_cancelled' => $aggregateCancelled, + 'products_sold' => $aggregateProducts, + 'attendees_registered' => $aggregateAttendees, + 'total_views' => $aggregateViews, + 'unique_views' => (int)round($aggregateViews * 0.65), + 'updated_at' => now(), + ]); + + $this->table( + ['Metric', 'Total over period'], + [ + ['Orders (completed)', $aggregateOrders], + ['Orders (cancelled)', $aggregateCancelled], + ['Products sold', $aggregateProducts], + ['Attendees', $aggregateAttendees], + ['Gross sales', number_format($aggregateGross, 2) . ' ' . $event->currency], + ['Tax', number_format($aggregateTax, 2) . ' ' . $event->currency], + ['Fees', number_format($aggregateFee, 2) . ' ' . $event->currency], + ['Refunded', number_format($aggregateRefunded, 2) . ' ' . $event->currency], + ['Page views', $aggregateViews], + ], + ); + }); + + $this->info('Done.'); + return self::SUCCESS; + } + + private function cleanup(int $eventId): void + { + $deletedOrders = DB::table('orders') + ->where('event_id', $eventId) + ->where('notes', self::SEED_NOTE) + ->delete(); + + $this->line("Cleaned up {$deletedOrders} prior seed orders. Daily statistics will be upserted for the seeded window."); + } + + private function randomOrderCount(int $daysAgo, bool $isWeekend): int + { + $base = $isWeekend ? random_int(4, 12) : random_int(1, 7); + + if ($daysAgo > 30) { + $base = (int)round($base * 0.6); + } + if (random_int(1, 100) <= 4) { + $base += random_int(8, 20); + } + return max(0, $base); + } + + private function randomChoice(array $items) + { + return $items[array_rand($items)]; + } +} diff --git a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php index 33f91ea0ba..6868a622f9 100644 --- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php +++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php @@ -9,28 +9,68 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class GetEventStatsAction extends BaseAction { + private const MAX_RANGE_DAYS = 370; + public function __construct( private readonly GetEventStatsHandler $eventStatsHandler ) { } + /** + * @throws ValidationException + */ public function __invoke(int $eventId, Request $request): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $dateRangePreset = $request->query('date_range', 'month'); + $validated = $this->validateDateRange($request); + $occurrenceIdQuery = $request->query('occurrence_id'); $stats = $this->eventStatsHandler->handle(EventStatsRequestDTO::fromArray([ 'event_id' => $eventId, - 'date_range_preset' => $dateRangePreset, + 'date_range_preset' => $request->query('date_range', 'month'), + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, 'occurrence_id' => $occurrenceIdQuery !== null ? (int)$occurrenceIdQuery : null, ])); return $this->resourceResponse(JsonResource::class, $stats); } + + /** + * @return array{start_date: ?string, end_date: ?string} + * @throws ValidationException + */ + private function validateDateRange(Request $request): array + { + $validated = Validator::make( + $request->only(['start_date', 'end_date']), + [ + 'start_date' => 'nullable|date|required_with:end_date|before_or_equal:end_date', + 'end_date' => 'nullable|date|required_with:start_date|after_or_equal:start_date', + ], + )->validate(); + + if (!empty($validated['start_date']) && !empty($validated['end_date'])) { + $days = Carbon::parse($validated['start_date'])->diffInDays(Carbon::parse($validated['end_date'])); + if ($days > self::MAX_RANGE_DAYS) { + throw ValidationException::withMessages([ + 'start_date' => __('Date range must be less than :days days.', ['days' => self::MAX_RANGE_DAYS]), + ]); + } + } + + return [ + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + ]; + } } diff --git a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php index 474bf84cc5..fdb3ac9fc4 100644 --- a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php +++ b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php @@ -8,23 +8,34 @@ use HiEvents\Services\Application\Handlers\Organizer\GetOrganizerStatsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class GetOrganizerStatsAction extends BaseAction { + private const MAX_RANGE_DAYS = 370; + public function __construct( private readonly GetOrganizerStatsHandler $getOrganizerStatsHandler, - ) - { - } + ) {} + /** + * @throws ValidationException + */ public function __invoke(Request $request, int $organizerId): JsonResponse { $this->isActionAuthorized($organizerId, OrganizerDomainObject::class); + $validated = $this->validateDateRange($request); + $organizerStats = $this->getOrganizerStatsHandler->handle(new GetOrganizerStatsRequestDTO( organizerId: $organizerId, accountId: $this->getAuthenticatedAccountId(), currencyCode: $request->get('currency_code'), + startDate: $validated['start_date'], + endDate: $validated['end_date'], + dateRangePreset: $request->query('date_range', 'month'), )); return $this->jsonResponse( @@ -32,4 +43,33 @@ public function __invoke(Request $request, int $organizerId): JsonResponse wrapInData: true, ); } + + /** + * @return array{start_date: ?string, end_date: ?string} + * @throws ValidationException + */ + private function validateDateRange(Request $request): array + { + $validated = Validator::make( + $request->only(['start_date', 'end_date']), + [ + 'start_date' => 'nullable|date|required_with:end_date|before_or_equal:end_date', + 'end_date' => 'nullable|date|required_with:start_date|after_or_equal:start_date', + ], + )->validate(); + + if (!empty($validated['start_date']) && !empty($validated['end_date'])) { + $days = Carbon::parse($validated['start_date'])->diffInDays(Carbon::parse($validated['end_date'])); + if ($days > self::MAX_RANGE_DAYS) { + throw ValidationException::withMessages([ + 'start_date' => __('Date range must be less than :days days.', ['days' => self::MAX_RANGE_DAYS]), + ]); + } + } + + return [ + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + ]; + } } diff --git a/backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php b/backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php new file mode 100644 index 0000000000..4c57cce253 --- /dev/null +++ b/backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php @@ -0,0 +1,15 @@ + @@ -32,15 +35,15 @@ public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePagina { return $this->handleResults($this->model ->select([ - 'organizers.' . OrganizerDomainObjectAbstract::ID, - 'organizers.' . OrganizerDomainObjectAbstract::NAME, - 'organizers.' . OrganizerDomainObjectAbstract::UPDATED_AT, + 'organizers.'.OrganizerDomainObjectAbstract::ID, + 'organizers.'.OrganizerDomainObjectAbstract::NAME, + 'organizers.'.OrganizerDomainObjectAbstract::UPDATED_AT, ]) ->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id') - ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) - ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT) - ->orderBy('organizers.' . OrganizerDomainObjectAbstract::ID) + ->where('organizers.'.OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) + ->where('organizer_settings.'.OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('organizers.'.OrganizerDomainObjectAbstract::DELETED_AT) + ->orderBy('organizers.'.OrganizerDomainObjectAbstract::ID) ->paginate($perPage, ['*'], 'page', $page)); } @@ -49,39 +52,65 @@ public function getSitemapOrganizerCount(): int return $this->model ->newQuery() ->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id') - ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) - ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT) + ->where('organizers.'.OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) + ->where('organizer_settings.'.OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('organizers.'.OrganizerDomainObjectAbstract::DELETED_AT) ->count(); } - public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO - { - $totalsQuery = <<= :startDate::date + AND eods.date <= :endDate::date + AND eods.deleted_at IS NULL + AND e.deleted_at IS NULL; +SQL; + + $totalsResult = $this->db->selectOne($totalsQuery, [ + 'organizerId' => $organizerId, + 'accountId' => $accountId, + 'currencyCode' => $currencyCode, + 'startDate' => $startDate, + 'endDate' => $endDate, + ]); + + $totalViewsQuery = <<<'SQL' + SELECT COALESCE(SUM(es.total_views), 0) AS total_views FROM event_statistics es JOIN events e ON e.id = es.event_id WHERE e.organizer_id = :organizerId AND e.account_id = :accountId AND e.currency = :currencyCode - AND es.deleted_at IS NULL; - SQL; + AND es.deleted_at IS NULL + AND e.deleted_at IS NULL; +SQL; - $totalsResult = $this->db->selectOne($totalsQuery, [ + $totalViewsResult = $this->db->selectOne($totalViewsQuery, [ 'organizerId' => $organizerId, 'accountId' => $accountId, 'currencyCode' => $currencyCode, ]); - $allOrganizersCurrenciesQuery = << $accountId, ]); + $dailyStats = $this->getDailyOrganizerStats( + organizerId: $organizerId, + accountId: $accountId, + currencyCode: $currencyCode, + startDate: $startDate, + endDate: $endDate, + ); + return new OrganizerStatsResponseDTO( - total_products_sold: (int)($totalsResult->total_products_sold ?? 0), - total_attendees_registered: (int)($totalsResult->attendees_registered ?? 0), - total_orders: (int)($totalsResult->total_orders ?? 0), - total_gross_sales: (float)($totalsResult->total_gross_sales ?? 0), - total_fees: (float)($totalsResult->total_fees ?? 0), - total_tax: (float)($totalsResult->total_tax ?? 0), - total_views: (int)($totalsResult->total_views ?? 0), - total_refunded: (float)($totalsResult->total_refunded ?? 0), + total_products_sold: (int) ($totalsResult->total_products_sold ?? 0), + total_attendees_registered: (int) ($totalsResult->attendees_registered ?? 0), + total_orders: (int) ($totalsResult->total_orders ?? 0), + total_gross_sales: (float) ($totalsResult->total_gross_sales ?? 0), + total_fees: (float) ($totalsResult->total_fees ?? 0), + total_tax: (float) ($totalsResult->total_tax ?? 0), + total_views: (int) ($totalViewsResult->total_views ?? 0), + total_refunded: (float) ($totalsResult->total_refunded ?? 0), currency_code: $currencyCode, + daily_stats: $dailyStats, + start_date: $startDate, + end_date: $endDate, all_organizers_currencies: array_map( - static fn($currency) => $currency->currency, + static fn ($currency) => $currency->currency, $allOrganizersCurrencies ), ); } + + private function getDailyOrganizerStats( + int $organizerId, + int $accountId, + string $currencyCode, + string $startDate, + string $endDate, + ): Collection { + $query = <<<'SQL' + WITH date_series AS ( + SELECT date::date + FROM generate_series( + :startDate::date, + :endDate::date, + '1 day' + ) AS gs(date) + ) + SELECT + ds.date, + COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered, + COALESCE(SUM(eods.products_sold), 0) AS products_sold, + COALESCE(SUM(eods.sales_total_gross), 0) AS total_sales_gross, + COALESCE(SUM(eods.orders_created), 0) AS orders_created, + COALESCE(SUM(eods.total_refunded), 0) AS total_refunded + FROM date_series ds + LEFT JOIN event_occurrence_daily_statistics eods + ON ds.date = eods.date + AND eods.deleted_at IS NULL + AND eods.event_id IN ( + SELECT e.id FROM events e + WHERE e.organizer_id = :organizerId + AND e.account_id = :accountId + AND e.currency = :currencyCode + AND e.deleted_at IS NULL + ) + GROUP BY ds.date + ORDER BY ds.date ASC; +SQL; + + $results = $this->db->select($query, [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'organizerId' => $organizerId, + 'accountId' => $accountId, + 'currencyCode' => $currencyCode, + ]); + + $currentTime = Carbon::now('UTC')->toTimeString(); + + return collect($results)->map(function (object $result) use ($currentTime) { + $dateTimeWithCurrentTime = (new Carbon($result->date))->setTimezone('UTC')->format('Y-m-d').' '.$currentTime; + + return new OrganizerDailyStatsResponseDTO( + date: $dateTimeWithCurrentTime, + attendees_registered: (int) $result->attendees_registered, + products_sold: (int) $result->products_sold, + total_sales_gross: (float) $result->total_sales_gross, + orders_created: (int) $result->orders_created, + total_refunded: (float) $result->total_refunded, + ); + }); + } } diff --git a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php index 069993a93b..97790b6f5f 100644 --- a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php @@ -13,7 +13,13 @@ */ interface OrganizerRepositoryInterface extends RepositoryInterface { - public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO; + public function getOrganizerStats( + int $organizerId, + int $accountId, + string $currencyCode, + string $startDate, + string $endDate, + ): OrganizerStatsResponseDTO; public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePaginator; diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php index 301ea04c99..ff712f27cd 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php @@ -7,8 +7,9 @@ class GetOrganizerStatsRequestDTO public function __construct( public readonly int $organizerId, public readonly int $accountId, - public ?string $currencyCode = null, - ) - { - } + public ?string $currencyCode = null, + public ?string $startDate = null, + public ?string $endDate = null, + public string $dateRangePreset = 'month', + ) {} } diff --git a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php index 7f29ac06a0..f60c892ccd 100644 --- a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Organizer; +use Carbon\Carbon; use HiEvents\Repository\DTO\Organizer\OrganizerStatsResponseDTO; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Application\Handlers\Organizer\DTO\GetOrganizerStatsRequestDTO; @@ -9,9 +10,7 @@ class GetOrganizerStatsHandler { - public function __construct(private readonly OrganizerRepositoryInterface $repository) - { - } + public function __construct(private readonly OrganizerRepositoryInterface $repository) {} public function handle(GetOrganizerStatsRequestDTO $statsRequestDTO): OrganizerStatsResponseDTO { @@ -24,10 +23,42 @@ public function handle(GetOrganizerStatsRequestDTO $statsRequestDTO): OrganizerS throw new ResourceNotFoundException('Organizer not found'); } + [$startDate, $endDate] = $this->resolveDateRange( + $statsRequestDTO->startDate, + $statsRequestDTO->endDate, + $statsRequestDTO->dateRangePreset, + ); + return $this->repository->getOrganizerStats( organizerId: $statsRequestDTO->organizerId, accountId: $statsRequestDTO->accountId, currencyCode: $statsRequestDTO->currencyCode ?? $organizer->getCurrency(), + startDate: $startDate, + endDate: $endDate, ); } + + /** + * @return array{0: string, 1: string} + */ + private function resolveDateRange(?string $startDate, ?string $endDate, string $preset): array + { + if ($startDate !== null && $endDate !== null) { + return [$startDate, $endDate]; + } + + $end = Carbon::now(); + + $start = match ($preset) { + 'week' => (clone $end)->subDays(7), + 'quarter' => (clone $end)->subDays(90), + 'year' => (clone $end)->subDays(365), + default => (clone $end)->subDays(30), + }; + + return [ + $start->format('Y-m-d H:i:s'), + $end->format('Y-m-d H:i:s'), + ]; + } } diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php index 518ebcf031..42aa522031 100644 --- a/backend/app/Services/Domain/Event/EventStatsFetchService.php +++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php @@ -39,39 +39,59 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp // query honest about that scope. $totalsQuery = <<<'SQL' SELECT - COALESCE(SUM(eos.products_sold), 0) AS total_products_sold, - COALESCE(SUM(eos.orders_created), 0) AS total_orders, - COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales, - COALESCE(SUM(eos.total_tax), 0) AS total_tax, - COALESCE(SUM(eos.total_fee), 0) AS total_fees, + COALESCE(SUM(eods.products_sold), 0) AS total_products_sold, + COALESCE(SUM(eods.orders_created), 0) AS total_orders, + COALESCE(SUM(eods.sales_total_gross), 0) AS total_gross_sales, + COALESCE(SUM(eods.total_tax), 0) AS total_tax, + COALESCE(SUM(eods.total_fee), 0) AS total_fees, 0 AS total_views, - COALESCE(SUM(eos.total_refunded), 0) AS total_refunded, - COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered - FROM event_occurrence_statistics eos - WHERE eos.event_occurrence_id = :occurrenceId - AND eos.event_id = :eventId - AND eos.deleted_at IS NULL; + COALESCE(SUM(eods.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered + FROM event_occurrence_daily_statistics eods + WHERE eods.event_occurrence_id = :occurrenceId + AND eods.event_id = :eventId + AND eods.deleted_at IS NULL + AND eods.date >= :startDate::date + AND eods.date <= :endDate::date; SQL; $totalsResult = $this->db->selectOne($totalsQuery, [ 'occurrenceId' => $occurrenceId, 'eventId' => $eventId, + 'startDate' => $requestData->start_date, + 'endDate' => $requestData->end_date, ]); } else { $totalsQuery = <<<'SQL' SELECT - COALESCE(SUM(eos.products_sold), 0) AS total_products_sold, - COALESCE(SUM(eos.orders_created), 0) AS total_orders, - COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales, - COALESCE(SUM(eos.total_tax), 0) AS total_tax, - COALESCE(SUM(eos.total_fee), 0) AS total_fees, - COALESCE((SELECT SUM(es.total_views) FROM event_statistics es WHERE es.event_id = :eventIdViews AND es.deleted_at IS NULL), 0) AS total_views, - COALESCE(SUM(eos.total_refunded), 0) AS total_refunded, - COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered - FROM event_occurrence_statistics eos - WHERE eos.event_id = :eventId - AND eos.deleted_at IS NULL; + COALESCE(SUM(eods.products_sold), 0) AS total_products_sold, + COALESCE(SUM(eods.orders_created), 0) AS total_orders, + COALESCE(SUM(eods.sales_total_gross), 0) AS total_gross_sales, + COALESCE(SUM(eods.total_tax), 0) AS total_tax, + COALESCE(SUM(eods.total_fee), 0) AS total_fees, + COALESCE(( + SELECT SUM(eds.total_views) + FROM event_daily_statistics eds + WHERE eds.event_id = :eventIdViews + AND eds.deleted_at IS NULL + AND eds.date >= :startDateViews::date + AND eds.date <= :endDateViews::date + ), 0) AS total_views, + COALESCE(SUM(eods.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered + FROM event_occurrence_daily_statistics eods + WHERE eods.event_id = :eventId + AND eods.deleted_at IS NULL + AND eods.date >= :startDate::date + AND eods.date <= :endDate::date; SQL; - $totalsResult = $this->db->selectOne($totalsQuery, ['eventId' => $eventId, 'eventIdViews' => $eventId]); + $totalsResult = $this->db->selectOne($totalsQuery, [ + 'eventId' => $eventId, + 'eventIdViews' => $eventId, + 'startDate' => $requestData->start_date, + 'endDate' => $requestData->end_date, + 'startDateViews' => $requestData->start_date, + 'endDateViews' => $requestData->end_date, + ]); } return new EventStatsResponseDTO( diff --git a/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php new file mode 100644 index 0000000000..fb0414bf2a --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php @@ -0,0 +1,217 @@ +repository = $this->app->make(OrganizerRepository::class); + + $user = User::factory()->withAccount()->create(); + $this->accountId = $user->accounts()->first()->id; + + $now = now()->toDateTimeString(); + + $this->organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Stats Organizer', + 'email' => 'stats-organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventAId = DB::table('events')->insertGetId([ + 'title' => 'Event A', + 'account_id' => $this->accountId, + 'user_id' => $user->id, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_a_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventBId = DB::table('events')->insertGetId([ + 'title' => 'Event B', + 'account_id' => $this->accountId, + 'user_id' => $user->id, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_b_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->occurrenceAId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_a_'.uniqid(), + 'event_id' => $this->eventAId, + 'start_date' => now()->addDay()->toDateTimeString(), + 'end_date' => now()->addDay()->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->occurrenceBId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_b_'.uniqid(), + 'event_id' => $this->eventBId, + 'start_date' => now()->addDays(2)->toDateTimeString(), + 'end_date' => now()->addDays(2)->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function test_get_organizer_stats_returns_daily_breakdown_and_aggregates_across_date_window(): void + { + // Seed 60 contiguous days of daily stats for both events. Per-event/day + // values are deterministic so we can verify aggregates exactly. + $today = Carbon::now()->startOfDay(); + $rangeStart = (clone $today)->subDays(59); + + $rows = []; + for ($i = 0; $i < 60; $i++) { + $date = (clone $rangeStart)->addDays($i)->toDateString(); + + $rows[] = $this->dailyRow( + eventId: $this->eventAId, + occurrenceId: $this->occurrenceAId, + date: $date, + productsSold: 2, + attendeesRegistered: 3, + salesGross: 10.00, + ordersCreated: 1, + totalRefunded: 1.50, + ); + $rows[] = $this->dailyRow( + eventId: $this->eventBId, + occurrenceId: $this->occurrenceBId, + date: $date, + productsSold: 5, + attendeesRegistered: 7, + salesGross: 25.00, + ordersCreated: 2, + totalRefunded: 0.50, + ); + } + DB::table('event_occurrence_daily_statistics')->insert($rows); + + // Query the most recent 30 days. + $endDate = (clone $today)->format('Y-m-d H:i:s'); + $startDate = (clone $today)->subDays(29)->format('Y-m-d H:i:s'); + + $stats = $this->repository->getOrganizerStats( + organizerId: $this->organizerId, + accountId: $this->accountId, + currencyCode: 'USD', + startDate: $startDate, + endDate: $endDate, + ); + + // Daily series spans the inclusive 30-day window. + $this->assertCount(30, $stats->daily_stats); + $stats->daily_stats->each(function ($row) { + $this->assertInstanceOf(OrganizerDailyStatsResponseDTO::class, $row); + }); + + // Each day across both events: products_sold=2+5=7, attendees=3+7=10, + // gross=10+25=35, orders=1+2=3, refunded=1.5+0.5=2. + $firstDay = $stats->daily_stats->first(); + $this->assertSame(7, $firstDay->products_sold); + $this->assertSame(10, $firstDay->attendees_registered); + $this->assertSame(35.0, $firstDay->total_sales_gross); + $this->assertSame(3, $firstDay->orders_created); + $this->assertSame(2.0, $firstDay->total_refunded); + + // Aggregates across the 30-day window equal the sum of the 30 daily rows. + $this->assertSame(7 * 30, $stats->total_products_sold); + $this->assertSame(10 * 30, $stats->total_attendees_registered); + $this->assertSame(3 * 30, $stats->total_orders); + $this->assertEqualsWithDelta(35.0 * 30, $stats->total_gross_sales, 0.001); + $this->assertEqualsWithDelta(2.0 * 30, $stats->total_refunded, 0.001); + + $this->assertSame('USD', $stats->currency_code); + $this->assertSame($startDate, $stats->start_date); + $this->assertSame($endDate, $stats->end_date); + + // all_organizers_currencies is preserved from the original behaviour. + $this->assertContains('USD', $stats->all_organizers_currencies); + } + + /** + * @return array + */ + private function dailyRow( + int $eventId, + int $occurrenceId, + string $date, + int $productsSold, + int $attendeesRegistered, + float $salesGross, + int $ordersCreated, + float $totalRefunded, + ): array { + $now = now()->toDateTimeString(); + + return [ + 'event_id' => $eventId, + 'event_occurrence_id' => $occurrenceId, + 'date' => $date, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'sales_total_gross' => $salesGross, + 'sales_total_before_additions' => $salesGross, + 'total_tax' => 0, + 'total_fee' => 0, + 'orders_created' => $ordersCreated, + 'orders_cancelled' => 0, + 'total_refunded' => $totalRefunded, + 'version' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } +} diff --git a/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php index bc86f48db6..bda6f11eee 100644 --- a/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php @@ -33,11 +33,13 @@ public function test_occurrence_scoped_stats_bind_event_id_with_occurrence_id(): ->shouldReceive('selectOne') ->once() ->with( - Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eos.event_occurrence_id = :occurrenceId') - && str_contains($sql, 'eos.event_id = :eventId')), + Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eods.event_occurrence_id = :occurrenceId') + && str_contains($sql, 'eods.event_id = :eventId')), [ 'occurrenceId' => 200, 'eventId' => 10, + 'startDate' => '2026-01-01', + 'endDate' => '2026-01-31', ], ) ->andReturn((object) [ diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index c440647a75..fd82275699 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -37,10 +37,15 @@ export const eventsClient = { return response.data; }, - getEventStats: async (eventId: IdParam, options: {occurrenceId?: IdParam; dateRange?: string} = {}) => { + getEventStats: async ( + eventId: IdParam, + options: {occurrenceId?: IdParam; dateRange?: string; startDate?: string; endDate?: string} = {}, + ) => { const params = new URLSearchParams(); if (options.occurrenceId) params.set('occurrence_id', String(options.occurrenceId)); if (options.dateRange) params.set('date_range', options.dateRange); + if (options.startDate) params.set('start_date', options.startDate); + if (options.endDate) params.set('end_date', options.endDate); const qs = params.toString(); const response = await api.get>(`events/${eventId}/stats${qs ? '?' + qs : ''}`); return response.data; diff --git a/frontend/src/api/organizer.client.ts b/frontend/src/api/organizer.client.ts index 5a9f88f2f5..e991867701 100644 --- a/frontend/src/api/organizer.client.ts +++ b/frontend/src/api/organizer.client.ts @@ -58,8 +58,17 @@ export const organizerClient = { return response.data; }, - getOrganizerStats: async (organizerId: IdParam, currencyCode: string) => { - const response = await api.get>('organizers/' + organizerId + '/stats?currency_code=' + currencyCode); + getOrganizerStats: async ( + organizerId: IdParam, + options: {currencyCode: string; startDate?: string; endDate?: string}, + ) => { + const params = new URLSearchParams(); + params.append('currency_code', options.currencyCode); + if (options.startDate) params.append('start_date', options.startDate); + if (options.endDate) params.append('end_date', options.endDate); + const response = await api.get>( + `organizers/${organizerId}/stats?${params.toString()}`, + ); return response.data; }, diff --git a/frontend/src/components/common/KpiGrid/KpiGrid.module.scss b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss new file mode 100644 index 0000000000..ab9804c46f --- /dev/null +++ b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss @@ -0,0 +1,100 @@ +@use "../../../styles/mixins.scss"; + +.grid { + background-color: var(--hi-color-border); + border: 0.5px solid var(--hi-color-border); + border-radius: var(--hi-radius-card); + overflow: hidden; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5px; + + @include mixins.respond-below(lg) { + grid-template-columns: repeat(2, 1fr); + } + + @include mixins.respond-below(sm) { + grid-template-columns: 1fr; + } +} + +.cell { + background-color: var(--hi-color-white); + padding: 16px; + display: flex; + flex-direction: column; +} + +.label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--hi-color-text-muted); + font-weight: 500; + margin-bottom: 8px; +} + +.middleRow { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.value { + font-size: 1.25rem; + font-weight: 500; + letter-spacing: -0.015em; + color: var(--hi-text); + line-height: 1.2; +} + +.sparkline { + width: 56px; + height: 20px; + flex-shrink: 0; +} + +.sparklineSpacer { + width: 56px; + height: 20px; + flex-shrink: 0; +} + +.delta { + margin-top: 8px; + font-size: 0.75rem; + display: inline-flex; + align-items: center; + gap: 2px; + line-height: 1.2; +} + +.deltaUp { + color: var(--hi-color-success); +} + +.deltaDown { + color: var(--hi-color-danger); +} + +.deltaFlat { + color: var(--hi-color-text-muted); +} + +.deltaNone { + color: var(--hi-color-text-muted); +} + +.deltaPlaceholder { + display: block; + margin-top: 8px; + height: 15px; +} + +.skeletonRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} diff --git a/frontend/src/components/common/KpiGrid/index.tsx b/frontend/src/components/common/KpiGrid/index.tsx new file mode 100644 index 0000000000..ca0ee92db8 --- /dev/null +++ b/frontend/src/components/common/KpiGrid/index.tsx @@ -0,0 +1,100 @@ +import {ReactNode} from "react"; +import {Skeleton} from "@mantine/core"; +import {Sparkline} from "@mantine/charts"; +import {IconArrowDownRight, IconArrowUpRight} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import classes from "./KpiGrid.module.scss"; + +interface KpiGridProps { + children: ReactNode; + className?: string; +} + +export const KpiGrid = ({children, className = ''}: KpiGridProps) => { + return ( +
+ {children} +
+ ); +}; + +export interface KpiCellDelta { + percent: number; + trend: 'up' | 'down' | 'flat'; +} + +interface KpiCellProps { + label: string; + value: string | number; + sparkline?: number[]; + delta?: KpiCellDelta | null; + isLoading?: boolean; +} + +const formatPercent = (percent: number, sign: '+' | '-') => { + const abs = Math.abs(percent).toFixed(1); + return `${sign}${abs}%`; +}; + +export const KpiCell = ({label, value, sparkline, delta, isLoading = false}: KpiCellProps) => { + if (isLoading) { + return ( +
+
{label}
+
+ + +
+ +
+ ); + } + + let deltaContent: ReactNode; + if (delta == null) { + deltaContent =