diff --git a/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php b/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php new file mode 100644 index 000000000..4a2b66de0 --- /dev/null +++ b/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php @@ -0,0 +1,36 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $events = $this->handler->handle(new GetUpcomingEventsDTO( + perPage: min((int)$request->query('per_page', 20), 100), + )); + + return $this->resourceResponse( + resource: EventResource::class, + data: $events + ); + } +} diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php index bedc030e6..2665dd2d7 100644 --- a/backend/app/Repository/Eloquent/AccountRepository.php +++ b/backend/app/Repository/Eloquent/AccountRepository.php @@ -37,12 +37,19 @@ public function getAllAccountsWithCounts(?string $search, int $perPage): LengthA { $query = $this->model ->select('accounts.*') - ->withCount(['events', 'users']); + ->withCount(['events', 'users']) + ->with(['users' => function ($query) { + $query->select('users.id', 'users.first_name', 'users.last_name', 'users.email') + ->withPivot('role'); + }]); if ($search) { $query->where(function ($q) use ($search) { - $q->where('name', 'like', "%{$search}%") - ->orWhere('email', 'like', "%{$search}%"); + $q->where('accounts.name', 'like', "{$search}%") + ->orWhere('accounts.email', 'like', "{$search}%") + ->orWhereHas('users', function ($userQuery) use ($search) { + $userQuery->where('users.email', 'like', "{$search}%"); + }); }); } diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index 5040fa424..9342f5b3a 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -83,4 +83,22 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag page: $params->page, ); } + + public function getUpcomingEventsForAdmin(int $perPage): LengthAwarePaginator + { + $now = now(); + $next24Hours = now()->addDay(); + + return $this->handleResults($this->model + ->select('events.*') + ->with(['account', 'organizer']) + ->where(EventDomainObjectAbstract::START_DATE, '>=', $now) + ->where(EventDomainObjectAbstract::START_DATE, '<=', $next24Hours) + ->whereIn(EventDomainObjectAbstract::STATUS, [ + EventStatus::LIVE->name, + EventStatus::DRAFT->name, + ]) + ->orderBy(EventDomainObjectAbstract::START_DATE, 'asc') + ->paginate($perPage)); + } } diff --git a/backend/app/Repository/Interfaces/EventRepositoryInterface.php b/backend/app/Repository/Interfaces/EventRepositoryInterface.php index d7c415e00..bd34afbf8 100644 --- a/backend/app/Repository/Interfaces/EventRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventRepositoryInterface.php @@ -17,4 +17,6 @@ interface EventRepositoryInterface extends RepositoryInterface public function findEventsForOrganizer(int $organizerId, int $accountId, QueryParamsDTO $params): LengthAwarePaginator; public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePaginator; + + public function getUpcomingEventsForAdmin(int $perPage): LengthAwarePaginator; } diff --git a/backend/app/Resources/Account/AdminAccountResource.php b/backend/app/Resources/Account/AdminAccountResource.php index 12cf5b1f0..c2063bc53 100644 --- a/backend/app/Resources/Account/AdminAccountResource.php +++ b/backend/app/Resources/Account/AdminAccountResource.php @@ -18,6 +18,15 @@ public function toArray(Request $request): array 'created_at' => $this->resource->created_at, 'events_count' => $this->resource->events_count ?? 0, 'users_count' => $this->resource->users_count ?? 0, + 'users' => $this->resource->users->map(function ($user) { + return [ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'role' => $user->pivot->role, + ]; + }), ]; } } diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php new file mode 100644 index 000000000..cad0764d4 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php @@ -0,0 +1,14 @@ +eventRepository->getUpcomingEventsForAdmin($dto->perPage); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index b9537223e..3a809cbc2 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -151,6 +151,7 @@ use HiEvents\Http\Actions\Users\UpdateMeAction; use HiEvents\Http\Actions\Users\UpdateUserAction; use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction; +use HiEvents\Http\Actions\Admin\Events\GetUpcomingEventsAction; use HiEvents\Http\Actions\Admin\Stats\GetAdminStatsAction; use HiEvents\Http\Actions\Admin\Users\GetAllUsersAction; use HiEvents\Http\Actions\Admin\Users\StartImpersonationAction; @@ -373,6 +374,7 @@ function (Router $router): void { $router->get('/stats', GetAdminStatsAction::class); $router->get('/accounts', GetAllAccountsAction::class); $router->get('/users', GetAllUsersAction::class); + $router->get('/events/upcoming', GetUpcomingEventsAction::class); $router->post('/impersonate/{user_id}', StartImpersonationAction::class); $router->post('/stop-impersonation', StopImpersonationAction::class); } diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 3841fed04..0325c4957 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -1,5 +1,5 @@ import {api} from "./client"; -import {GenericDataResponse, GenericPaginatedResponse, IdParam, User} from "../types"; +import {GenericPaginatedResponse, IdParam, User} from "../types"; export interface AdminUser extends User { accounts?: AccountWithRole[]; @@ -12,6 +12,14 @@ export interface AccountWithRole { role: string; } +export interface AdminAccountUser { + id: IdParam; + first_name: string; + last_name: string; + email: string; + role: string; +} + export interface AdminAccount { id: IdParam; name: string; @@ -21,6 +29,7 @@ export interface AdminAccount { created_at: string; events_count: number; users_count: number; + users: AdminAccountUser[]; } export interface AdminStats { @@ -86,6 +95,15 @@ export const adminClient = { return response.data; }, + getUpcomingEvents: async (perPage: number = 10) => { + const response = await api.get>('admin/events/upcoming', { + params: { + per_page: perPage, + } + }); + return response.data; + }, + startImpersonation: async (userId: IdParam, accountId: IdParam) => { const response = await api.post( `admin/impersonate/${userId}`, diff --git a/frontend/src/components/common/AdminAccountsTable/AdminAccountsTable.module.scss b/frontend/src/components/common/AdminAccountsTable/AdminAccountsTable.module.scss index 79b38b367..3c8525feb 100644 --- a/frontend/src/components/common/AdminAccountsTable/AdminAccountsTable.module.scss +++ b/frontend/src/components/common/AdminAccountsTable/AdminAccountsTable.module.scss @@ -83,3 +83,35 @@ text-align: center; padding: 3rem 1rem; } + +.usersSection { + padding-top: 1rem; + border-top: 1px solid var(--mantine-color-default-border); +} + +.usersList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.userItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--mantine-color-gray-0); + border-radius: var(--mantine-radius-sm); + transition: background-color 0.2s; + + &:hover { + background: var(--mantine-color-gray-1); + } +} + +.userDetails { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} diff --git a/frontend/src/components/common/AdminAccountsTable/index.tsx b/frontend/src/components/common/AdminAccountsTable/index.tsx index 60162af61..ebcf352ec 100644 --- a/frontend/src/components/common/AdminAccountsTable/index.tsx +++ b/frontend/src/components/common/AdminAccountsTable/index.tsx @@ -1,14 +1,17 @@ -import {Badge, Stack, Text} from "@mantine/core"; +import {Badge, Button, Stack, Text} from "@mantine/core"; import {t} from "@lingui/macro"; import {AdminAccount} from "../../../api/admin.client"; import {IconCalendar, IconWorld, IconBuildingBank, IconUsers} from "@tabler/icons-react"; import classes from "./AdminAccountsTable.module.scss"; +import {IdParam} from "../../../types"; interface AdminAccountsTableProps { accounts: AdminAccount[]; + onImpersonate: (userId: IdParam, accountId: IdParam) => void; + isLoading?: boolean; } -const AdminAccountsTable = ({accounts}: AdminAccountsTableProps) => { +const AdminAccountsTable = ({accounts, onImpersonate, isLoading}: AdminAccountsTableProps) => { if (!accounts || accounts.length === 0) { return (
@@ -23,6 +26,24 @@ const AdminAccountsTable = ({accounts}: AdminAccountsTableProps) => { return date.toLocaleDateString(); }; + const getRoleBadgeColor = (role: string) => { + switch (role) { + case 'ADMIN': + return 'blue'; + case 'ORGANIZER': + return 'green'; + case 'SUPERADMIN': + return 'red'; + default: + return 'gray'; + } + }; + + const canImpersonate = (role: string) => { + return role !== 'SUPERADMIN'; + }; + + return (
{accounts.map((account) => ( @@ -52,6 +73,41 @@ const AdminAccountsTable = ({accounts}: AdminAccountsTableProps) => {
+ {account.users && account.users.length > 0 && ( +
+ {t`Users`} +
+ {account.users.map((user) => ( +
+
+ + {user.first_name} {user.last_name} + + {user.email} + + {user.role} + +
+ {canImpersonate(user.role) && ( + + )} +
+ ))} +
+
+ )} +
diff --git a/frontend/src/components/routes/admin/Accounts/index.tsx b/frontend/src/components/routes/admin/Accounts/index.tsx index fefb07a49..672049914 100644 --- a/frontend/src/components/routes/admin/Accounts/index.tsx +++ b/frontend/src/components/routes/admin/Accounts/index.tsx @@ -3,9 +3,14 @@ import {t} from "@lingui/macro"; import {IconSearch} from "@tabler/icons-react"; import {useState, useEffect} from "react"; import {useGetAllAccounts} from "../../../../queries/useGetAllAccounts"; +import {useStartImpersonation} from "../../../../mutations/useStartImpersonation"; import AdminAccountsTable from "../../../common/AdminAccountsTable"; +import {showError, showSuccess} from "../../../../utilites/notifications"; +import {IdParam} from "../../../../types"; +import {useNavigate} from "react-router"; const Accounts = () => { + const navigate = useNavigate(); const [page, setPage] = useState(1); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); @@ -16,6 +21,8 @@ const Accounts = () => { search: debouncedSearch }); + const startImpersonationMutation = useStartImpersonation(); + useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(search); @@ -25,6 +32,25 @@ const Accounts = () => { return () => clearTimeout(timer); }, [search]); + const handleImpersonate = (userId: IdParam, accountId: IdParam) => { + startImpersonationMutation.mutate({userId, accountId}, { + onSuccess: (response) => { + showSuccess(response.message || t`Impersonation started`); + if (response.redirect_url) { + window.location.href = response.redirect_url; + } else { + navigate('/manage/events'); + } + }, + onError: (error: any) => { + showError( + error?.response?.data?.message || + t`Failed to start impersonation. Please try again.` + ); + } + }); + }; + return ( @@ -44,7 +70,11 @@ const Accounts = () => { ) : ( - + )} {accountsData?.meta && accountsData.meta.last_page > 1 && ( diff --git a/frontend/src/components/routes/admin/Dashboard/index.tsx b/frontend/src/components/routes/admin/Dashboard/index.tsx index a1b9354de..4f340ee47 100644 --- a/frontend/src/components/routes/admin/Dashboard/index.tsx +++ b/frontend/src/components/routes/admin/Dashboard/index.tsx @@ -1,13 +1,39 @@ -import {Container, Title, Text, Paper, Stack, Group, SimpleGrid, Skeleton} from "@mantine/core"; +import {Container, Title, Text, Paper, Stack, Group, SimpleGrid, Skeleton, Badge, Anchor} from "@mantine/core"; import {t, Trans} from "@lingui/macro"; -import {IconUsers, IconBuildingBank, IconCalendarEvent, IconTicket} from "@tabler/icons-react"; +import {IconUsers, IconBuildingBank, IconCalendarEvent, IconTicket, IconClock} from "@tabler/icons-react"; import {useGetMe} from "../../../../queries/useGetMe"; import {useGetAdminStats} from "../../../../queries/useGetAdminStats"; +import {useGetUpcomingEvents} from "../../../../queries/useGetUpcomingEvents"; +import {eventHomepageUrl} from "../../../../utilites/urlHelper"; +import dayjs from "dayjs"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; + +dayjs.extend(utc); +dayjs.extend(timezone); const AdminDashboard = () => { const {data: user} = useGetMe(); const {data: stats, isLoading} = useGetAdminStats(); - + const {data: upcomingEvents, isLoading: isLoadingEvents} = useGetUpcomingEvents(10); + + const formatEventDate = (dateString: string, eventTimezone?: string) => { + const eventDate = dayjs.utc(dateString); + const now = dayjs(); + const diffMinutes = eventDate.diff(now, 'minute'); + const diffHours = eventDate.diff(now, 'hour'); + + if (diffMinutes < 60) { + return t`In ${diffMinutes} minutes`; + } else if (diffHours < 24) { + return t`In ${diffHours} hours`; + } + + return eventTimezone + ? eventDate.tz(eventTimezone).format('MMM D, h:mma') + : eventDate.format('MMM D, h:mma'); + }; + return ( @@ -95,6 +121,56 @@ const AdminDashboard = () => { + +
+ + <Group gap="xs"> + <IconClock size={24} /> + <Trans>Events Starting in Next 24 Hours</Trans> + </Group> + + + {isLoadingEvents ? ( + + + + + + ) : upcomingEvents?.data && upcomingEvents.data.length > 0 ? ( + + {upcomingEvents.data.map((event: any) => ( + + +
+ + {event.title} + + {formatEventDate(event.start_date, event.timezone)} + + +
+ + View Event + +
+
+ ))} +
+ ) : ( + + + + + No events starting in the next 24 hours + + + + )} +
); diff --git a/frontend/src/queries/useGetUpcomingEvents.ts b/frontend/src/queries/useGetUpcomingEvents.ts new file mode 100644 index 000000000..9261fae39 --- /dev/null +++ b/frontend/src/queries/useGetUpcomingEvents.ts @@ -0,0 +1,9 @@ +import {useQuery} from "@tanstack/react-query"; +import {adminClient} from "../api/admin.client"; + +export const useGetUpcomingEvents = (perPage: number = 10) => { + return useQuery({ + queryKey: ['admin', 'events', 'upcoming', perPage], + queryFn: () => adminClient.getUpcomingEvents(perPage), + }); +};