diff --git a/backend/app/DomainObjects/AttendeeDomainObject.php b/backend/app/DomainObjects/AttendeeDomainObject.php index f23daafcf8..e6396e0f20 100644 --- a/backend/app/DomainObjects/AttendeeDomainObject.php +++ b/backend/app/DomainObjects/AttendeeDomainObject.php @@ -9,6 +9,8 @@ class AttendeeDomainObject extends Generated\AttendeeDomainObjectAbstract implements IsSortable, IsFilterable { + public const TICKET_NAME_SORT_KEY = 'ticket_name'; + private ?OrderDomainObject $order = null; private ?ProductDomainObject $product = null; @@ -30,6 +32,10 @@ public static function getAllowedSorts(): AllowedSorts { return new AllowedSorts( [ + self::TICKET_NAME_SORT_KEY => [ + 'asc' => __('Ticket Name A-Z'), + 'desc' => __('Ticket Name Z-A'), + ], self::CREATED_AT => [ 'asc' => __('Older First'), 'desc' => __('Newest First'), @@ -64,6 +70,7 @@ public static function getAllowedFilterFields(): array return [ self::STATUS, self::PRODUCT_ID, + self::PRODUCT_PRICE_ID, ]; } diff --git a/backend/app/Http/Actions/Attendees/GetAttendeesAction.php b/backend/app/Http/Actions/Attendees/GetAttendeesAction.php index 06ac1be2d6..65a4582315 100644 --- a/backend/app/Http/Actions/Attendees/GetAttendeesAction.php +++ b/backend/app/Http/Actions/Attendees/GetAttendeesAction.php @@ -4,38 +4,29 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\Value\Relationship; -use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Resources\Attendee\AttendeeResource; +use HiEvents\Services\Application\Handlers\Attendee\GetAttendeesHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -/** - * @todo move to handler - * @todo - add validation for filter fields - */ class GetAttendeesAction extends BaseAction { - private AttendeeRepositoryInterface $attendeeRepository; - - public function __construct(AttendeeRepositoryInterface $attendeeRepository) + public function __construct( + private readonly GetAttendeesHandler $getAttendeesHandler, + ) { - $this->attendeeRepository = $attendeeRepository; } public function __invoke(int $eventId, Request $request): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $attendees = $this->attendeeRepository - ->loadRelation(new Relationship( - domainObject: OrderDomainObject::class, - name: 'order' - )) - ->findByEventId($eventId, QueryParamsDTO::fromArray($request->query->all())); + $attendees = $this->getAttendeesHandler->handle( + eventId: $eventId, + queryParams: QueryParamsDTO::fromArray($request->query->all()) + ); return $this->filterableResourceResponse( resource: AttendeeResource::class, diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php index ea554917f8..0f4e7bddfa 100644 --- a/backend/app/Repository/Eloquent/AttendeeRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeRepository.php @@ -7,6 +7,7 @@ use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\Http\DTO\FilterFieldDTO; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\Attendee; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -76,11 +77,22 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware $this->model = $this->model->select('attendees.*') ->join('orders', 'orders.id', '=', 'attendees.order_id') - ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]) - ->orderBy( - 'attendees.' . ($params->sort_by ?? AttendeeDomainObject::getDefaultSort()), - $params->sort_direction ?? 'desc', - ); + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]); + + if ($params->filter_fields && $params->filter_fields->isNotEmpty()) { + $this->applyFilterFields($params, AttendeeDomainObject::getAllowedFilterFields(), prefix: 'attendees'); + } + + $sortBy = $params->sort_by ?? AttendeeDomainObject::getDefaultSort(); + $sortDirection = $params->sort_direction ?? AttendeeDomainObject::getDefaultSortDirection(); + + if ($sortBy === AttendeeDomainObject::TICKET_NAME_SORT_KEY) { + $this->model = $this->model + ->leftJoin('products', 'products.id', '=', 'attendees.product_id') + ->orderBy('products.title', $sortDirection); + } else { + $this->model = $this->model->orderBy('attendees.' . $sortBy, $sortDirection); + } return $this->paginateWhere( where: $where, diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index 3db2e501d5..3e1b850fd1 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -387,10 +387,14 @@ protected function handleSingleResult( return $this->hydrateDomainObjectFromModel($model, $domainObjectOverride); } - protected function applyFilterFields(QueryParamsDTO $params, array $allowedFilterFields = []): void + protected function applyFilterFields( + QueryParamsDTO $params, + array $allowedFilterFields = [], + ?string $prefix = null, + ): void { if ($params->filter_fields && $params->filter_fields->isNotEmpty()) { - $params->filter_fields->each(function ($filterField) use ($allowedFilterFields) { + $params->filter_fields->each(function ($filterField) use ($prefix, $allowedFilterFields) { if (!in_array($filterField->field, $allowedFilterFields, true)) { return; } @@ -412,6 +416,8 @@ protected function applyFilterFields(QueryParamsDTO $params, array $allowedFilte sprintf('Operator %s is not supported', $filterField->operator) ); + $field = $prefix ? $prefix . '.' . $filterField->field : $filterField->field; + // Special handling for IN operator if ($operator === 'IN') { // Ensure value is array or convert comma-separated string to array @@ -420,12 +426,12 @@ protected function applyFilterFields(QueryParamsDTO $params, array $allowedFilte : explode(',', $filterField->value); $this->model = $this->model->whereIn( - column: $filterField->field, + column: $field, values: $value ); } else { $this->model = $this->model->where( - column: $filterField->field, + column: $field, operator: $operator, value: $isNull ? null : $filterField->value, ); diff --git a/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php new file mode 100644 index 0000000000..d8e5881ebf --- /dev/null +++ b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php @@ -0,0 +1,33 @@ +attendeeRepository + ->loadRelation(new Relationship( + domainObject: OrderDomainObject::class, + name: 'order' + )) + ->loadRelation(new Relationship( + domainObject: AttendeeCheckInDomainObject::class, + name: 'check_ins' + )) + ->findByEventId($eventId, $queryParams); + } +} diff --git a/frontend/src/api/check-in-list.client.ts b/frontend/src/api/check-in-list.client.ts index 2f347cec9c..017c293c6a 100644 --- a/frontend/src/api/check-in-list.client.ts +++ b/frontend/src/api/check-in-list.client.ts @@ -4,7 +4,8 @@ import { CheckInListRequest, GenericDataResponse, GenericPaginatedResponse, - IdParam, QueryFilters, + IdParam, + QueryFilters, } from "../types"; import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; @@ -17,8 +18,9 @@ export const checkInListClient = { const response = await api.put>(`events/${eventId}/check-in-lists/${checkInListId}`, checkInList); return response.data; }, - all: async (eventId: IdParam, pagination: QueryFilters) => { - const response = await api.get>(`events/${eventId}/check-in-lists` + queryParamsHelper.buildQueryString(pagination)); + all: async (eventId: IdParam, pagination: QueryFilters | null = null) => { + const paginationQuery = (pagination) ? queryParamsHelper.buildQueryString(pagination as QueryFilters) : ''; + const response = await api.get>(`events/${eventId}/check-in-lists` + paginationQuery); return response.data; }, get: async (eventId: IdParam, checkInListId: IdParam) => { diff --git a/frontend/src/components/common/AttendeeTable/index.tsx b/frontend/src/components/common/AttendeeTable/index.tsx index 35cbfddb7f..734a89dcf5 100644 --- a/frontend/src/components/common/AttendeeTable/index.tsx +++ b/frontend/src/components/common/AttendeeTable/index.tsx @@ -1,13 +1,13 @@ -import {Anchor, Avatar, Badge, Button, Table as MantineTable,} from '@mantine/core'; +import {Anchor, Avatar, Badge, Button, Table as MantineTable, Tooltip, ActionIcon, Popover, Group} from '@mantine/core'; import {Attendee, MessageType} from "../../../types.ts"; -import {IconMailForward, IconPlus, IconSend, IconTrash, IconUserCog} from "@tabler/icons-react"; +import {IconMailForward, IconPlus, IconSend, IconTrash, IconUserCog, IconQrcode, IconNote, IconCopy} from "@tabler/icons-react"; import {getInitials, getProductFromEvent} from "../../../utilites/helpers.ts"; import {Table, TableHead} from "../Table"; -import {useDisclosure} from "@mantine/hooks"; +import {useDisclosure, useClipboard} from "@mantine/hooks"; import {SendMessageModal} from "../../modals/SendMessageModal"; import {useState} from "react"; import {NoResultsSplash} from "../NoResultsSplash"; -import {NavLink, useParams} from "react-router"; +import {useParams} from "react-router"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import Truncate from "../Truncate"; import {notifications} from "@mantine/notifications"; @@ -17,8 +17,12 @@ import {t, Trans} from "@lingui/macro"; import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useResendAttendeeTicket} from "../../../mutations/useResendAttendeeTicket.ts"; import {ManageAttendeeModal} from "../../modals/ManageAttendeeModal"; +import {ManageOrderModal} from "../../modals/ManageOrderModal"; import {ActionMenu} from '../ActionMenu'; import {AttendeeStatusBadge} from "../AttendeeStatusBadge"; +import {CheckInStatusModal} from "../CheckInStatusModal"; +import {prettyDate} from "../../../utilites/dates.ts"; +import {IdParam} from "../../../types.ts"; interface AttendeeTableProps { attendees: Attendee[]; @@ -29,10 +33,15 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) const {eventId} = useParams(); const [isMessageModalOpen, messageModal] = useDisclosure(false); const [isViewModalOpen, viewModalOpen] = useDisclosure(false); + const [isCheckInModalOpen, checkInModal] = useDisclosure(false); + const [isOrderModalOpen, orderModal] = useDisclosure(false); + const [emailPopoverId, setEmailPopoverId] = useState(null); const [selectedAttendee, setSelectedAttendee] = useState(); + const [selectedOrderId, setSelectedOrderId] = useState(); const {data: event} = useGetEvent(eventId); const modifyMutation = useModifyAttendee(); const resendTicketMutation = useResendAttendeeTicket(); + const clipboard = useClipboard({timeout: 2000}); const handleModalClick = (attendee: Attendee, modal: { open: () => void @@ -99,6 +108,30 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) }) }; + const getCheckInCount = (attendee: Attendee) => { + return attendee.check_ins?.length || 0; + }; + + const hasCheckIns = (attendee: Attendee) => { + return getCheckInCount(attendee) > 0; + }; + + const handleCopyEmail = (email: string, attendeeId: number | undefined) => { + clipboard.copy(email); + showSuccess(t`Email address copied to clipboard`); + setEmailPopoverId(null); + }; + + const handleMessageFromEmail = (attendee: Attendee) => { + setEmailPopoverId(null); + handleModalClick(attendee, messageModal); + }; + + const handleOrderClick = (orderId: IdParam) => { + setSelectedOrderId(orderId); + orderModal.open(); + }; + return ( <> @@ -110,11 +143,16 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) {t`Order`}{t`Ticket`}{t`Status`} + {t`Check-In Status`} + {attendees.map((attendee) => { + const checkInCount = getCheckInCount(attendee); + const hasChecked = hasCheckIns(attendee); + return ( @@ -131,18 +169,65 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) - - - + { + if (!opened) setEmailPopoverId(null); + }} + width={200} + position="bottom" + withArrow + shadow="md" + > + + setEmailPopoverId(attendee.id || null)} + style={{cursor: 'pointer'}} + > + + + + + + + + + + - - - {attendee.order?.public_id} - - + + handleOrderClick(attendee.order_id)} + style={{cursor: 'pointer'}} + > + + {attendee.order?.public_id} + + + + + + handleModalClick(attendee, checkInModal)} + aria-label={t`View check-in status`} + > + + {hasChecked && ( + + {checkInCount} + + )} + + + + + {attendee.notes && ( + 100 + ? t`Click to view notes` + : attendee.notes + } + multiline + w={attendee.notes.length > 100 ? 'auto' : 300} + withArrow + > + { + if (attendee.notes && attendee.notes.length > 100) { + handleModalClick(attendee, viewModalOpen); + } + }} + aria-label={t`View notes`} + > + + + + )} + } + {(selectedAttendee && isCheckInModalOpen && event?.timezone) && } + {(selectedOrderId && isOrderModalOpen) && } ); diff --git a/frontend/src/components/common/CheckIn/AttendeeList.tsx b/frontend/src/components/common/CheckIn/AttendeeList.tsx index aac71f716b..7662b397a2 100644 --- a/frontend/src/components/common/CheckIn/AttendeeList.tsx +++ b/frontend/src/components/common/CheckIn/AttendeeList.tsx @@ -30,6 +30,10 @@ export const AttendeeList = ({ return t`Cannot Check In`; } + if (attendee.status === 'CANCELLED') { + return t`Cannot Check In (Cancelled)`; + } + if (attendee.check_in) { return t`Check Out`; } @@ -38,7 +42,7 @@ export const AttendeeList = ({ }; const getButtonColor = (attendee: Attendee) => { - if (attendee.check_in) { + if (attendee.check_in || attendee.status === 'CANCELLED') { return 'red'; } if (attendee.status === 'AWAITING_PAYMENT' && !allowOrdersAwaitingOfflinePaymentToCheckIn) { @@ -74,6 +78,11 @@ export const AttendeeList = ({
{attendee.first_name} {attendee.last_name}
+ {attendee.status === 'CANCELLED' ? ( +
+ {t`Ticket Cancelled`} +
+ ) : null}
{attendee.email}
@@ -96,7 +105,7 @@ export const AttendeeList = ({ onClickSound?.(); onCheckInToggle(attendee); }} - disabled={isCheckInPending || isDeletePending} + disabled={isCheckInPending || isDeletePending || attendee.status === 'CANCELLED'} loading={isCheckInPending || isDeletePending} color={getButtonColor(attendee)} > diff --git a/frontend/src/components/common/CheckInStatusModal/CheckInStatusModal.module.scss b/frontend/src/components/common/CheckInStatusModal/CheckInStatusModal.module.scss new file mode 100644 index 0000000000..dd158255fa --- /dev/null +++ b/frontend/src/components/common/CheckInStatusModal/CheckInStatusModal.module.scss @@ -0,0 +1,10 @@ +.listItem { + padding: 12px; + border-radius: 8px; + background-color: var(--mantine-color-gray-0); + border: 1px solid var(--mantine-color-gray-2); + + &:hover { + background-color: var(--mantine-color-gray-1); + } +} diff --git a/frontend/src/components/common/CheckInStatusModal/index.tsx b/frontend/src/components/common/CheckInStatusModal/index.tsx new file mode 100644 index 0000000000..1144c6185b --- /dev/null +++ b/frontend/src/components/common/CheckInStatusModal/index.tsx @@ -0,0 +1,186 @@ +import {Modal, Stack, Text, Group, Badge, Box, ScrollArea} from '@mantine/core'; +import {IconCheck, IconX, IconQrcode} from '@tabler/icons-react'; +import {Attendee, CheckInList, IdParam} from '../../../types'; +import {t, Trans} from '@lingui/macro'; +import {prettyDate} from '../../../utilites/dates'; +import {useGetEventCheckInLists} from '../../../queries/useGetCheckInLists'; +import {LoadingMask} from '../LoadingMask'; +import classes from './CheckInStatusModal.module.scss'; + +interface CheckInStatusModalProps { + attendee: Attendee; + eventTimezone: string; + eventId: IdParam; + isOpen: boolean; + onClose: () => void; +} + +export const CheckInStatusModal = ({ + attendee, + eventTimezone, + eventId, + isOpen, + onClose +}: CheckInStatusModalProps) => { + const {data: checkInListsResponse, isLoading, ...rest} = useGetEventCheckInLists(eventId); + + console.log('CheckInStatusModal - checkInListsResponse:', checkInListsResponse, 'isLoading:', isLoading, 'rest:', rest); + + if (isLoading) { + return ( + + + + + + + + Check-In Status + + + + + +
+ +
+
+
+
+ ); + } + + const checkInLists = checkInListsResponse?.data || []; + const attendeeCheckIns = attendee.check_ins || []; + + const getCheckInForList = (listId: number | undefined) => { + return attendeeCheckIns.find(ci => ci.check_in_list_id === listId); + }; + + const isAttendeeEligibleForList = (list: CheckInList) => { + if (!list.products || list.products.length === 0) { + return true; + } + return list.products.some(product => product.id === attendee.product_id); + }; + + const eligibleLists = checkInLists.filter(list => isAttendeeEligibleForList(list)); + const ineligibleLists = checkInLists.filter(list => !isAttendeeEligibleForList(list)); + + const renderListItem = (list: CheckInList, isEligible: boolean) => { + const checkIn = getCheckInForList(list.id); + const isCheckedIn = !!checkIn; + + return ( + + + + {isEligible ? ( + isCheckedIn ? ( + + ) : ( + + ) + ) : ( + + )} + + + {list.name} + + {isCheckedIn && checkIn.created_at && ( + + {prettyDate(checkIn.created_at, eventTimezone)} + + )} + {!isEligible && ( + + Attendee's ticket not included in this list + + )} + + + {isEligible ? ( + + {isCheckedIn ? t`Checked In` : t`Not Checked In`} + + ) : ( + + {t`Not Eligible`} + + )} + + + ); + }; + + return ( + + + + + + + + Check-In Status + + + + + + + + + + {attendee.first_name} {attendee.last_name} + + + + {attendee.public_id} + + + + {checkInLists.length === 0 ? ( + + No check-in lists available for this event. + + ) : ( + + + {eligibleLists.length > 0 && ( + + + Eligible Check-In Lists + + {eligibleLists.map((list) => renderListItem(list, true))} + + )} + + {ineligibleLists.length > 0 && ( + + + Other Lists (Ticket Not Included) + + {ineligibleLists.map((list) => renderListItem(list, false))} + + )} + + + )} + + + + + ); +}; diff --git a/frontend/src/components/common/FilterModal/index.tsx b/frontend/src/components/common/FilterModal/index.tsx index 801d1c4615..e572f043a7 100644 --- a/frontend/src/components/common/FilterModal/index.tsx +++ b/frontend/src/components/common/FilterModal/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Button, Group, Modal, MultiSelect, Stack, Text, TextInput} from '@mantine/core'; +import {Button, Group, Modal, MultiSelect, Select, Stack, Text, TextInput} from '@mantine/core'; import {useDisclosure} from '@mantine/hooks'; import {IconFilter} from '@tabler/icons-react'; import {t} from '@lingui/macro'; @@ -25,7 +25,7 @@ interface FilterModalProps { const normalizeFilterValue = (value: any, type: string): any => { if (value === undefined || value === null) { - return []; + return type === 'multi-select' ? [] : null; } switch (type) { @@ -48,6 +48,18 @@ const normalizeFilterValue = (value: any, type: string): any => { return []; } + case 'single-select': { + if (Array.isArray(value)) { + return value[0] || null; + } + + if (value?.value) { + return typeof value.value === 'string' ? value.value : null; + } + + return typeof value === 'string' ? value : null; + } + case 'text': { return value; } @@ -163,6 +175,25 @@ export const FilterModal: React.FC = ({ ); } + case 'single-select': { + return ( +