diff --git a/backend/app/Http/Actions/Admin/Organizers/GetAllOrganizersAction.php b/backend/app/Http/Actions/Admin/Organizers/GetAllOrganizersAction.php new file mode 100644 index 0000000000..960ac0fd91 --- /dev/null +++ b/backend/app/Http/Actions/Admin/Organizers/GetAllOrganizersAction.php @@ -0,0 +1,35 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $organizers = $this->handler->handle(new GetAllOrganizersDTO( + perPage: min((int) $request->query('per_page', 20), 100), + search: $request->query('search'), + )); + + return $this->resourceResponse( + resource: AdminOrganizerResource::class, + data: $organizers, + ); + } +} diff --git a/backend/app/Http/Actions/Events/UpdateEventAction.php b/backend/app/Http/Actions/Events/UpdateEventAction.php index 87b2c788cc..f033d3783d 100644 --- a/backend/app/Http/Actions/Events/UpdateEventAction.php +++ b/backend/app/Http/Actions/Events/UpdateEventAction.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\CannotChangeCurrencyException; +use HiEvents\Exceptions\OrganizerNotFoundException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Event\UpdateEventRequest; use HiEvents\Resources\Event\EventResource; @@ -46,6 +47,10 @@ public function __invoke(UpdateEventRequest $request, int $eventId): JsonRespons throw ValidationException::withMessages([ 'currency' => $exception->getMessage(), ]); + } catch (OrganizerNotFoundException $exception) { + throw ValidationException::withMessages([ + 'organizer_id' => $exception->getMessage(), + ]); } return $this->resourceResponse(EventResource::class, $event); diff --git a/backend/app/Http/Request/Event/UpdateEventRequest.php b/backend/app/Http/Request/Event/UpdateEventRequest.php index 64861ea23b..6040a5f33b 100644 --- a/backend/app/Http/Request/Event/UpdateEventRequest.php +++ b/backend/app/Http/Request/Event/UpdateEventRequest.php @@ -13,8 +13,8 @@ class UpdateEventRequest extends BaseRequest public function rules(): array { - $rules = $this->eventRules(); - unset($rules['organizer_id']); + $rules = $this->eventRules(); + $rules['organizer_id'] = ['sometimes', 'integer']; return $rules; } diff --git a/backend/app/Models/Organizer.php b/backend/app/Models/Organizer.php index 9eb57f2bdc..d216f35fe2 100644 --- a/backend/app/Models/Organizer.php +++ b/backend/app/Models/Organizer.php @@ -3,6 +3,7 @@ namespace HiEvents\Models; use HiEvents\Models\Traits\HasImages; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; @@ -12,6 +13,11 @@ class Organizer extends BaseModel use SoftDeletes; use HasImages; + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + public function events(): HasMany { return $this->hasMany(Event::class); diff --git a/backend/app/Repository/Eloquent/OrganizerRepository.php b/backend/app/Repository/Eloquent/OrganizerRepository.php index 6449239786..1eea9b24e4 100644 --- a/backend/app/Repository/Eloquent/OrganizerRepository.php +++ b/backend/app/Repository/Eloquent/OrganizerRepository.php @@ -55,6 +55,23 @@ public function getSitemapOrganizerCount(): int ->count(); } + public function getAllOrganizersForAdmin(?string $search, int $perPage): LengthAwarePaginator + { + $query = $this->model->query()->with(['account', 'organizer_settings']); + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('name', 'ilike', "%$search%") + ->orWhere('email', 'ilike', "%$search%") + ->orWhereHas('account', function ($accountQuery) use ($search) { + $accountQuery->where('name', 'ilike', "%$search%"); + }); + }); + } + + return $query->orderBy('created_at', 'desc')->paginate($perPage); + } + public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO { $totalsQuery = << $this->getId(), + 'organizer_id' => $this->getOrganizerId(), 'title' => $this->getTitle(), 'category' => $this->getCategory(), 'description' => $this->getDescription(), diff --git a/backend/app/Resources/Organizer/AdminOrganizerResource.php b/backend/app/Resources/Organizer/AdminOrganizerResource.php new file mode 100644 index 0000000000..cc802e7d74 --- /dev/null +++ b/backend/app/Resources/Organizer/AdminOrganizerResource.php @@ -0,0 +1,32 @@ +resource->account; + + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'phone' => $this->resource->phone, + 'website' => $this->resource->website, + 'currency' => $this->resource->currency, + 'timezone' => $this->resource->timezone, + 'status' => $this->resource->status, + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'account' => $account ? [ + 'id' => $account->id, + 'name' => $account->name, + 'email' => $account->email, + ] : null, + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrganizersDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrganizersDTO.php new file mode 100644 index 0000000000..1e750d4ad5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrganizersDTO.php @@ -0,0 +1,13 @@ +organizerRepository->getAllOrganizersForAdmin( + search: $dto->search, + perPage: $dto->perPage, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php index 0f30609d07..857c73f612 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php @@ -27,6 +27,7 @@ public function __construct( public readonly ?string $location = null, public readonly ?AddressDTO $location_details = null, public readonly ?string $status = EventStatus::DRAFT->name, + public readonly ?int $organizer_id = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 8f284ff631..154e458812 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -8,9 +8,11 @@ use HiEvents\Events\EventUpdateEvent; use HiEvents\Exceptions\CannotChangeCurrencyException; use HiEvents\Helper\DateHelper; +use HiEvents\Exceptions\OrganizerNotFoundException; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventDTO; +use HiEvents\Services\Domain\Organizer\OrganizerFetchService; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; @@ -26,6 +28,7 @@ public function __construct( private DatabaseManager $databaseManager, private OrderRepositoryInterface $orderRepository, private HtmlPurifierService $purifier, + private OrganizerFetchService $organizerFetchService, ) { } @@ -60,6 +63,7 @@ private function fetchExistingEvent(UpdateEventDTO $eventData) /** * @throws CannotChangeCurrencyException + * @throws OrganizerNotFoundException */ private function updateEventAttributes(UpdateEventDTO $eventData): void { @@ -69,20 +73,28 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void $this->checkForCompletedOrders($eventData); } + $attributes = [ + 'title' => $eventData->title, + 'category' => $eventData->category?->value ?? $existingEvent->getCategory(), + 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone), + 'end_date' => $eventData->end_date + ? DateHelper::convertToUTC($eventData->end_date, $eventData->timezone) + : null, + 'description' => $this->purifier->purify($eventData->description), + 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(), + 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), + 'location' => $eventData->location, + 'location_details' => $eventData->location_details?->toArray(), + ]; + + if ($eventData->organizer_id !== null && $eventData->organizer_id !== $existingEvent->getOrganizerId()) { + // Throws OrganizerNotFoundException if the organizer is not in this account. + $this->organizerFetchService->fetchOrganizer($eventData->organizer_id, $eventData->account_id); + $attributes['organizer_id'] = $eventData->organizer_id; + } + $this->eventRepository->updateWhere( - attributes: [ - 'title' => $eventData->title, - 'category' => $eventData->category?->value ?? $existingEvent->getCategory(), - 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone), - 'end_date' => $eventData->end_date - ? DateHelper::convertToUTC($eventData->end_date, $eventData->timezone) - : null, - 'description' => $this->purifier->purify($eventData->description), - 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(), - 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), - 'location' => $eventData->location, - 'location_details' => $eventData->location_details?->toArray(), - ], + attributes: $attributes, where: [ 'id' => $eventData->id, 'account_id' => $eventData->account_id, diff --git a/backend/routes/api.php b/backend/routes/api.php index e3947d0804..5b5a408018 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -194,6 +194,7 @@ use HiEvents\Http\Actions\Admin\GetMessagingTiersAction; use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction; +use HiEvents\Http\Actions\Admin\Organizers\GetAllOrganizersAction as GetAllAdminOrganizersAction; use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction; use HiEvents\Http\Actions\Admin\GetSystemInfoAction; use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction; @@ -461,6 +462,7 @@ function (Router $router): void { $router->put('/configurations/{configuration_id}', UpdateConfigurationAction::class); $router->delete('/configurations/{configuration_id}', DeleteConfigurationAction::class); $router->get('/users', GetAllUsersAction::class); + $router->get('/organizers', GetAllAdminOrganizersAction::class); $router->get('/events', GetAllAdminEventsAction::class); $router->get('/events/upcoming', GetUpcomingEventsAction::class); $router->get('/orders', GetAllOrdersAction::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php new file mode 100644 index 0000000000..71c974991f --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php @@ -0,0 +1,183 @@ +eventRepository = m::mock(EventRepositoryInterface::class); + $orderRepository = m::mock(OrderRepositoryInterface::class); + $dispatcher = m::mock(Dispatcher::class); + $purifier = m::mock(HtmlPurifierService::class); + $databaseManager = m::mock(DatabaseManager::class); + $this->organizerFetchService = m::mock(OrganizerFetchService::class); + + $databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn ($callback) => $callback()); + + $purifier->shouldReceive('purify') + ->andReturnUsing(fn ($value) => $value); + + $dispatcher->shouldReceive('dispatchEvent')->byDefault(); + + $this->handler = new UpdateEventHandler( + $this->eventRepository, + $dispatcher, + $databaseManager, + $orderRepository, + $purifier, + $this->organizerFetchService, + ); + } + + private function makeExistingEvent(int $organizerId = 5): EventDomainObject + { + $event = new EventDomainObject; + $event->setId(1) + ->setAccountId(10) + ->setOrganizerId($organizerId) + ->setCurrency('USD') + ->setTimezone('UTC') + ->setCategory('OTHER') + ->setTitle('Existing'); + + return $event; + } + + private function makeDto(?int $organizerId = null): UpdateEventDTO + { + return new UpdateEventDTO( + title: 'Updated', + category: null, + account_id: 10, + id: 1, + start_date: '2026-01-01 10:00:00', + end_date: null, + description: 'desc', + timezone: 'UTC', + currency: 'USD', + location: null, + location_details: null, + status: 'DRAFT', + organizer_id: $organizerId, + ); + } + + public function test_reassigns_organizer_when_organizer_id_provided_and_different(): void + { + $existing = $this->makeExistingEvent(organizerId: 5); + + $this->eventRepository->shouldReceive('findFirstWhere') + ->andReturn($existing); + + $this->organizerFetchService->shouldReceive('fetchOrganizer') + ->once() + ->with(7, 10) + ->andReturn(new OrganizerDomainObject); + + $this->eventRepository->shouldReceive('updateWhere') + ->once() + ->withArgs(function (array $attributes, array $where) { + return ($attributes['organizer_id'] ?? null) === 7 + && $where === ['id' => 1, 'account_id' => 10]; + }) + ->andReturn(1); + + $this->handler->handle($this->makeDto(organizerId: 7)); + + $this->assertTrue(true); + } + + public function test_does_not_include_organizer_id_when_omitted(): void + { + $existing = $this->makeExistingEvent(organizerId: 5); + + $this->eventRepository->shouldReceive('findFirstWhere') + ->andReturn($existing); + + $this->organizerFetchService->shouldNotReceive('fetchOrganizer'); + + $this->eventRepository->shouldReceive('updateWhere') + ->once() + ->withArgs(function (array $attributes) { + return ! array_key_exists('organizer_id', $attributes); + }) + ->andReturn(1); + + $this->handler->handle($this->makeDto(organizerId: null)); + + $this->assertTrue(true); + } + + public function test_does_not_reassign_when_organizer_id_matches_current(): void + { + $existing = $this->makeExistingEvent(organizerId: 5); + + $this->eventRepository->shouldReceive('findFirstWhere') + ->andReturn($existing); + + $this->organizerFetchService->shouldNotReceive('fetchOrganizer'); + + $this->eventRepository->shouldReceive('updateWhere') + ->once() + ->withArgs(function (array $attributes) { + return ! array_key_exists('organizer_id', $attributes); + }) + ->andReturn(1); + + $this->handler->handle($this->makeDto(organizerId: 5)); + + $this->assertTrue(true); + } + + public function test_rejects_cross_account_organizer(): void + { + $existing = $this->makeExistingEvent(organizerId: 5); + + $this->eventRepository->shouldReceive('findFirstWhere') + ->andReturn($existing); + + $this->organizerFetchService->shouldReceive('fetchOrganizer') + ->once() + ->with(99, 10) + ->andThrow(new OrganizerNotFoundException('Organizer 99 not found')); + + $this->eventRepository->shouldNotReceive('updateWhere'); + + $this->expectException(OrganizerNotFoundException::class); + + $this->handler->handle($this->makeDto(organizerId: 99)); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 4fde19eac9..2a492de93b 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -220,6 +220,30 @@ export interface GetAllOrdersParams { sort_direction?: 'asc' | 'desc'; } +export interface GetAllOrganizersParams { + page?: number; + per_page?: number; + search?: string; +} + +export interface AdminOrganizer { + id: IdParam; + name: string; + email: string; + phone: string | null; + website: string | null; + currency: string; + timezone: string; + status: string; + created_at: string; + updated_at: string; + account: { + id: IdParam; + name: string; + email: string; + } | null; +} + export interface AdminEventStatistics { total_gross_sales: number; products_sold: number; @@ -418,6 +442,17 @@ export const adminClient = { return response.data; }, + getAllOrganizers: async (params: GetAllOrganizersParams = {}) => { + const response = await api.get>('admin/organizers', { + params: { + page: params.page || 1, + per_page: params.per_page || 20, + search: params.search || undefined, + } + }); + return response.data; + }, + getAllOrders: async (params: GetAllOrdersParams = {}) => { const response = await api.get>('admin/orders', { params: { diff --git a/frontend/src/components/common/AdminOrganizersTable/AdminOrganizersTable.module.scss b/frontend/src/components/common/AdminOrganizersTable/AdminOrganizersTable.module.scss new file mode 100644 index 0000000000..d75b542335 --- /dev/null +++ b/frontend/src/components/common/AdminOrganizersTable/AdminOrganizersTable.module.scss @@ -0,0 +1,143 @@ +.cardsContainer { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 1rem; + width: 100%; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.organizerCard { + display: block; + width: 100%; + text-align: left; + font: inherit; + color: inherit; + background: white; + border: 1px solid var(--mantine-color-gray-2); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + padding: 0; + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-color: var(--mantine-color-primary-3); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-5); + outline-offset: 2px; + } +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1.25rem; + border-bottom: 1px solid var(--mantine-color-gray-1); + gap: 1rem; +} + +.organizerInfo { + display: flex; + flex-direction: column; + gap: 0.375rem; + flex: 1; + min-width: 0; +} + +.organizerName { + font-weight: 600; + color: var(--mantine-color-gray-9); + font-size: 1.125rem; + margin: 0; + line-height: 1.3; + word-break: break-word; +} + +.organizerEmail { + font-size: 0.875rem; + color: var(--mantine-color-gray-6); + word-break: break-all; +} + +.cardBody { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sectionLabel { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--mantine-color-gray-6); +} + +.accountItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem; + background: var(--mantine-color-gray-0); + border-radius: 8px; + border: 1px solid transparent; +} + +.accountName { + color: var(--mantine-color-gray-9); + font-size: 0.875rem; + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardFooter { + padding-top: 0.75rem; + border-top: 1px solid var(--mantine-color-gray-1); +} + +.footerInfo { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.footerItem { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--mantine-color-gray-6); + + svg { + flex-shrink: 0; + } +} + +.emptyState { + text-align: center; + padding: 4rem 2rem; + color: var(--mantine-color-gray-6); + background: var(--mantine-color-gray-0); + border-radius: 12px; + border: 1px solid var(--mantine-color-gray-2); +} diff --git a/frontend/src/components/common/AdminOrganizersTable/index.tsx b/frontend/src/components/common/AdminOrganizersTable/index.tsx new file mode 100644 index 0000000000..3ca5e84d14 --- /dev/null +++ b/frontend/src/components/common/AdminOrganizersTable/index.tsx @@ -0,0 +1,100 @@ +import {Badge, Text} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {useNavigate} from "react-router"; +import {AdminOrganizer} from "../../../api/admin.client"; +import {IconCalendar, IconCoin, IconWorld} from "@tabler/icons-react"; +import classes from "./AdminOrganizersTable.module.scss"; + +interface AdminOrganizersTableProps { + organizers: AdminOrganizer[]; +} + +const AdminOrganizersTable = ({organizers}: AdminOrganizersTableProps) => { + const navigate = useNavigate(); + + if (!organizers || organizers.length === 0) { + return ( +
+ {t`No organizers found`} +
+ ); + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'LIVE': + return 'green'; + case 'DRAFT': + return 'gray'; + case 'ARCHIVED': + return 'red'; + default: + return 'gray'; + } + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString(); + }; + + return ( +
+ {organizers.map((organizer) => ( + + ))} +
+ ); +}; + +export default AdminOrganizersTable; diff --git a/frontend/src/components/layouts/Admin/index.tsx b/frontend/src/components/layouts/Admin/index.tsx index 9a6e665c84..3d1f409580 100644 --- a/frontend/src/components/layouts/Admin/index.tsx +++ b/frontend/src/components/layouts/Admin/index.tsx @@ -1,4 +1,4 @@ -import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail} from "@tabler/icons-react"; +import {IconBuilding, IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import {NavItem, BreadcrumbItem} from "../AppLayout/types"; import AppLayout from "../AppLayout"; @@ -13,6 +13,7 @@ const AdminLayout = () => { {link: '', label: t`Dashboard`, icon: IconLayoutDashboard}, {link: 'accounts', label: t`Accounts`, icon: IconBuildingBank}, {link: 'users', label: t`Users`, icon: IconUsers}, + {link: 'organizers', label: t`Organizers`, icon: IconBuilding}, {link: 'events', label: t`Events`, icon: IconCalendar}, {link: 'orders', label: t`Orders`, icon: IconReceipt}, {link: 'messages', label: t`Messages`, icon: IconMail}, diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index 95fef67ec5..841afa3220 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -15,6 +15,7 @@ import { IconReceipt, IconSend, IconSettings, + IconArrowsExchange, IconShare, IconStar, IconTicket, @@ -39,6 +40,7 @@ import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useUpdateEventStatus} from "../../../mutations/useUpdateEventStatus.ts"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {ShareModal} from "../../modals/ShareModal"; +import {ChangeEventOrganizerModal} from "../../modals/ChangeEventOrganizerModal"; import {EventLiveCelebrationModal} from "../../modals/EventLiveCelebrationModal"; import {useDisclosure} from "@mantine/hooks"; import {TopBarButton} from "../../common/TopBarButton"; @@ -55,6 +57,7 @@ const EventLayout = () => { const [opened, {open, close}] = useDisclosure(false); const [celebrationOpened, {open: openCelebration, close: closeCelebration}] = useDisclosure(false); + const [changeOrganizerOpened, {open: openChangeOrganizer, close: closeChangeOrganizer}] = useDisclosure(false); const statusToggleMutation = useUpdateEventStatus(); @@ -84,7 +87,7 @@ const EventLayout = () => { {link: '/manage/organizer/' + event?.organizer?.id, label: t`Organizer Dashboard`, icon: IconArrowLeft}, // 1. OVERVIEW - {label: t`Overview`}, + {label: t`Event Overview`}, { link: 'getting-started', label: t`Getting Started`, @@ -201,6 +204,14 @@ const EventLayout = () => {
{event && ( <> + + + )) + )} +
+ )} + + ); +}; diff --git a/frontend/src/components/routes/admin/Organizers/index.tsx b/frontend/src/components/routes/admin/Organizers/index.tsx new file mode 100644 index 0000000000..c05fab28df --- /dev/null +++ b/frontend/src/components/routes/admin/Organizers/index.tsx @@ -0,0 +1,84 @@ +import {Alert, Button, Container, Group, Pagination, Skeleton, Stack, TextInput, Title} from "@mantine/core"; +import {useDisclosure} from "@mantine/hooks"; +import {t} from "@lingui/macro"; +import {IconInfoCircle, IconPlus, IconSearch} from "@tabler/icons-react"; +import {useEffect, useState} from "react"; +import {useQueryClient} from "@tanstack/react-query"; +import {useGetAllOrganizers, GET_ALL_ORGANIZERS_QUERY_KEY} from "../../../../queries/useGetAllOrganizers"; +import AdminOrganizersTable from "../../../common/AdminOrganizersTable"; +import {CreateOrganizerModal} from "../../../modals/CreateOrganizerModal"; + +const Organizers = () => { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [createOpened, {open: openCreate, close: closeCreate}] = useDisclosure(false); + const queryClient = useQueryClient(); + + const {data: organizersData, isLoading} = useGetAllOrganizers({ + page, + per_page: 20, + search: debouncedSearch, + }); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 500); + + return () => clearTimeout(timer); + }, [search]); + + const handleCreateClose = () => { + closeCreate(); + queryClient.invalidateQueries({queryKey: [GET_ALL_ORGANIZERS_QUERY_KEY]}); + }; + + return ( + + + + {t`Organizers`} + + + + } variant="light" color="blue"> + {t`Creating from here adds the organizer to your own account. To create one inside another account, impersonate a user in that account first.`} + + + } + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + + {isLoading ? ( + + + + + + ) : ( + + )} + + {organizersData?.meta && organizersData.meta.last_page > 1 && ( + + )} + + + {createOpened && } + + ); +}; + +export default Organizers; diff --git a/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx b/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx index ac7446d9f5..64328e13cc 100644 --- a/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx @@ -3,9 +3,10 @@ import {Button, Select, TextInput} from "@mantine/core"; import {useForm} from "@mantine/form"; import {useParams} from "react-router"; import {useGetEvent} from "../../../../../../queries/useGetEvent.ts"; +import {useGetOrganizers} from "../../../../../../queries/useGetOrganizers.ts"; import {useEffect} from "react"; import {useUpdateEvent} from "../../../../../../mutations/useUpdateEvent.ts"; -import {Event} from "../../../../../../types.ts"; +import {Event, OrganizerStatus} from "../../../../../../types.ts"; import {InputGroup} from "../../../../../common/InputGroup"; import {Card} from "../../../../../common/Card"; import {Editor} from "../../../../../common/Editor"; @@ -20,8 +21,18 @@ import {EventCategories} from "../../../../../../constants/eventCategories.ts"; export const EventDetailsForm = () => { const {eventId} = useParams(); const eventQuery = useGetEvent(eventId); + const organizersQuery = useGetOrganizers(); const updateMutation = useUpdateEvent(); - const form = useForm({ + const form = useForm<{ + title: string; + description: string; + start_date: string; + end_date: string; + timezone: string; + currency: string; + category: string; + organizer_id: string; + }>({ initialValues: { title: '', description: '', @@ -30,6 +41,7 @@ export const EventDetailsForm = () => { timezone: '', currency: '', category: '', + organizer_id: '', } }); const formErrorHandle = useFormErrorResponseHandler(); @@ -44,13 +56,19 @@ export const EventDetailsForm = () => { timezone: eventQuery.data.timezone, currency: eventQuery.data.currency, category: eventQuery.data.category, + organizer_id: eventQuery.data.organizer_id ? String(eventQuery.data.organizer_id) : '', }); } }, [eventQuery.isFetched]); - const handleSubmit = (values: Partial) => { + const handleSubmit = (values: typeof form.values) => { + const eventData: Partial = { + ...values, + organizer_id: values.organizer_id ? Number(values.organizer_id) : undefined, + }; + updateMutation.mutate({ - eventData: values, + eventData, eventId: eventId, }, { onSuccess: () => { @@ -89,6 +107,22 @@ export const EventDetailsForm = () => { clearable /> +