diff --git a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php
index 6097de8955..be3ca97e0e 100644
--- a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php
@@ -28,6 +28,7 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst
final public const UPDATED_AT = 'updated_at';
final public const DELETED_AT = 'deleted_at';
final public const LOCALE = 'locale';
+ final public const NOTES = 'notes';
protected int $id;
protected int $order_id;
@@ -47,6 +48,7 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst
protected string $updated_at;
protected ?string $deleted_at = null;
protected string $locale = 'en';
+ protected ?string $notes = null;
public function toArray(): array
{
@@ -69,6 +71,7 @@ public function toArray(): array
'updated_at' => $this->updated_at ?? null,
'deleted_at' => $this->deleted_at ?? null,
'locale' => $this->locale ?? null,
+ 'notes' => $this->notes ?? null,
];
}
@@ -269,4 +272,15 @@ public function getLocale(): string
{
return $this->locale;
}
+
+ public function setNotes(?string $notes): self
+ {
+ $this->notes = $notes;
+ return $this;
+ }
+
+ public function getNotes(): ?string
+ {
+ return $this->notes;
+ }
}
diff --git a/backend/app/Http/Actions/Attendees/EditAttendeeAction.php b/backend/app/Http/Actions/Attendees/EditAttendeeAction.php
index 504be54eef..ede204d4fc 100644
--- a/backend/app/Http/Actions/Attendees/EditAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/EditAttendeeAction.php
@@ -37,6 +37,7 @@ public function __invoke(EditAttendeeRequest $request, int $eventId, int $attend
'product_price_id' => $request->input('product_price_id'),
'event_id' => $eventId,
'attendee_id' => $attendeeId,
+ 'notes' => $request->input('notes'),
]));
} catch (NoTicketsAvailableException $exception) {
throw ValidationException::withMessages([
diff --git a/backend/app/Http/Actions/Orders/GetOrderAction.php b/backend/app/Http/Actions/Orders/GetOrderAction.php
index 9773221e56..b72c5581e8 100644
--- a/backend/app/Http/Actions/Orders/GetOrderAction.php
+++ b/backend/app/Http/Actions/Orders/GetOrderAction.php
@@ -3,6 +3,7 @@
namespace HiEvents\Http\Actions\Orders;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject;
use HiEvents\Http\Actions\BaseAction;
@@ -21,6 +22,8 @@ public function __construct(OrderRepositoryInterface $orderRepository)
public function __invoke(int $eventId, int $orderId): JsonResponse
{
+ $this->isActionAuthorized($eventId, EventDomainObject::class);
+
$order = $this->orderRepository
->loadRelation(OrderItemDomainObject::class)
->loadRelation(AttendeeDomainObject::class)
diff --git a/backend/app/Http/Request/Attendee/EditAttendeeRequest.php b/backend/app/Http/Request/Attendee/EditAttendeeRequest.php
index 0badf3043d..780c42a80b 100644
--- a/backend/app/Http/Request/Attendee/EditAttendeeRequest.php
+++ b/backend/app/Http/Request/Attendee/EditAttendeeRequest.php
@@ -15,6 +15,7 @@ public function rules(): array
'last_name' => RulesHelper::REQUIRED_STRING,
'product_id' => RulesHelper::REQUIRED_NUMERIC,
'product_price_id' => RulesHelper::REQUIRED_NUMERIC,
+ 'notes' => RulesHelper::OPTIONAL_TEXT_MEDIUM_LENGTH,
];
}
@@ -29,6 +30,7 @@ public function messages(): array
'product_price_id.required' => __('Product price is required'),
'product_id.numeric' => '',
'product_price_id.numeric' => '',
+ 'notes.max' => __('Notes must be less than 2000 characters'),
];
}
}
diff --git a/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php b/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php
index 11e19e91a5..87a7ccdb97 100644
--- a/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php
+++ b/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php
@@ -12,7 +12,7 @@ public function rules(): array
'name' => ['string', 'required', 'max:50'],
'description' => ['string', 'max:255', 'nullable'],
'is_hidden' => ['boolean', 'required'],
- 'no_products_message' => ['string', 'max:255', 'required'],
+ 'no_products_message' => ['string', 'max:255', 'nullable'],
];
}
}
diff --git a/backend/app/Resources/Attendee/AttendeeResource.php b/backend/app/Resources/Attendee/AttendeeResource.php
index a4b79c84a5..bc7b324f53 100644
--- a/backend/app/Resources/Attendee/AttendeeResource.php
+++ b/backend/app/Resources/Attendee/AttendeeResource.php
@@ -30,6 +30,7 @@ public function toArray(Request $request): array
'public_id' => $this->getPublicId(),
'short_id' => $this->getShortId(),
'locale' => $this->getLocale(),
+ 'notes' => $this->getNotes(),
'product' => $this->when(
!is_null($this->getProduct()),
fn() => new ProductResource($this->getProduct()),
diff --git a/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php
index d683a42877..155c602bc3 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php
@@ -7,13 +7,14 @@
class EditAttendeeDTO extends BaseDTO
{
public function __construct(
- public string $first_name,
- public string $last_name,
- public string $email,
- public int $product_id,
- public int $product_price_id,
- public int $event_id,
- public int $attendee_id,
+ public string $first_name,
+ public string $last_name,
+ public string $email,
+ public int $product_id,
+ public int $product_price_id,
+ public int $event_id,
+ public int $attendee_id,
+ public ?string $notes = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
index c6f7c874f5..567e1d4d94 100644
--- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
@@ -6,6 +6,7 @@
use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
+use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Exceptions\NoTicketsAvailableException;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
@@ -59,6 +60,7 @@ private function updateAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomai
'last_name' => $editAttendeeDTO->last_name,
'email' => $editAttendeeDTO->email,
'product_id' => $editAttendeeDTO->product_id,
+ 'notes' => $editAttendeeDTO->notes,
], [
'event_id' => $editAttendeeDTO->event_id,
]);
@@ -70,6 +72,7 @@ private function updateAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomai
*/
private function validateProductId(EditAttendeeDTO $editAttendeeDTO): void
{
+ /** @var ProductDomainObject $product */
$product = $this->productRepository
->loadRelation(ProductPriceDomainObject::class)
->findFirstWhere([
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php
index 17d50cc33b..73784755b2 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php
@@ -21,7 +21,7 @@ public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObje
isHidden: $dto->is_hidden,
eventId: $dto->event_id,
description: $dto->description,
- noProductsMessage: $dto->no_products_message,
+ noProductsMessage: $dto->no_products_message ?? __('There are no products available in this category'),
);
}
}
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php
index 83f7f3683c..105adde141 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php
@@ -21,7 +21,7 @@ public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObje
'name' => $dto->name,
'is_hidden' => $dto->is_hidden,
'description' => $dto->description,
- 'no_products_message' => $dto->no_products_message,
+ 'no_products_message' => $dto->no_products_message ?? __('There are no products available in this category'),
],
where: [
'id' => $dto->product_category_id,
diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php
index 96dffb1782..cb58643f0b 100644
--- a/backend/app/Services/Domain/Event/DuplicateEventService.php
+++ b/backend/app/Services/Domain/Event/DuplicateEventService.php
@@ -5,6 +5,7 @@
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\CheckInListDomainObject;
use HiEvents\DomainObjects\Enums\EventImageType;
+use HiEvents\DomainObjects\Enums\QuestionBelongsTo;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
@@ -79,8 +80,12 @@ public function duplicateEvent(
cloneEventSettings: $duplicateSettings,
);
+ if ($duplicateQuestions) {
+ $this->clonePerOrderQuestions($event, $newEvent->getId());
+ }
+
if ($duplicateProducts) {
- $this->cloneExistingProducts(
+ $this->cloneExistingTickets(
event: $event,
newEventId: $newEvent->getId(),
duplicateQuestions: $duplicateQuestions,
@@ -131,7 +136,7 @@ private function cloneExistingEvent(EventDomainObject $event, bool $cloneEventSe
/**
* @throws Throwable
*/
- private function cloneExistingProducts(
+ private function cloneExistingTickets(
EventDomainObject $event,
int $newEventId,
bool $duplicateQuestions,
@@ -140,20 +145,20 @@ private function cloneExistingProducts(
bool $duplicateCheckInLists,
): array
{
- $oldProductToNewProductMap = [];
+ $oldTicketToNewTicketMap = [];
- foreach ($event->getProducts() as $product) {
- $product->setEventId($newEventId);
- $newProduct = $this->createProductService->createProduct(
- product: $product,
+ foreach ($event->getProducts() as $ticket) {
+ $ticket->setEventId($newEventId);
+ $newTicket = $this->createProductService->createTicket(
+ ticket: $ticket,
accountId: $event->getAccountId(),
- taxAndFeeIds: $product->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(),
+ taxAndFeeIds: $ticket->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(),
);
- $oldProductToNewProductMap[$product->getId()] = $newProduct->getId();
+ $oldTicketToNewTicketMap[$ticket->getId()] = $newTicket->getId();
}
if ($duplicateQuestions) {
- $this->cloneQuestions($event, $newEventId, $oldProductToNewProductMap);
+ $this->clonePerTicketQuestions($event, $newEventId, $oldTicketToNewTicketMap);
}
if ($duplicatePromoCodes) {
@@ -174,23 +179,47 @@ private function cloneExistingProducts(
/**
* @throws Throwable
*/
- private function cloneQuestions(EventDomainObject $event, int $newEventId, array $oldProductToNewProductMap): void
+ private function clonePerTicketQuestions(EventDomainObject $event, int $newEventId, array $oldTicketToNewTicketMap): void
{
foreach ($event->getQuestions() as $question) {
- $this->createQuestionService->createQuestion(
- (new QuestionDomainObject())
- ->setTitle($question->getTitle())
- ->setEventId($newEventId)
- ->setBelongsTo($question->getBelongsTo())
- ->setType($question->getType())
- ->setRequired($question->getRequired())
- ->setOptions($question->getOptions())
- ->setIsHidden($question->getIsHidden()),
- array_map(
- static fn(ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()],
- $question->getProducts()?->all(),
- ),
- );
+ if ($question->getBelongsTo() === QuestionBelongsTo::TICKET->name) {
+ $this->createQuestionService->createQuestion(
+ (new QuestionDomainObject())
+ ->setTitle($question->getTitle())
+ ->setEventId($newEventId)
+ ->setBelongsTo($question->getBelongsTo())
+ ->setType($question->getType())
+ ->setRequired($question->getRequired())
+ ->setOptions($question->getOptions())
+ ->setIsHidden($question->getIsHidden()),
+ array_map(
+ static fn(ProductDomainObject $ticket) => $oldTicketToNewTicketMap[$ticket->getId()],
+ $question->getTickets()?->all(),
+ ),
+ );
+ }
+ }
+ }
+
+ /**
+ * @throws Throwable
+ */
+ private function clonePerOrderQuestions(EventDomainObject $event, int $newEventId): void
+ {
+ foreach ($event->getQuestions() as $question) {
+ if ($question->getBelongsTo() === QuestionBelongsTo::ORDER->name) {
+ $this->createQuestionService->createQuestion(
+ (new QuestionDomainObject())
+ ->setTitle($question->getTitle())
+ ->setEventId($newEventId)
+ ->setBelongsTo($question->getBelongsTo())
+ ->setType($question->getType())
+ ->setRequired($question->getRequired())
+ ->setOptions($question->getOptions())
+ ->setIsHidden($question->getIsHidden()),
+ [],
+ );
+ }
}
}
diff --git a/backend/app/Validators/Rules/RulesHelper.php b/backend/app/Validators/Rules/RulesHelper.php
index 20826cfe69..a68729c2de 100644
--- a/backend/app/Validators/Rules/RulesHelper.php
+++ b/backend/app/Validators/Rules/RulesHelper.php
@@ -16,4 +16,5 @@ class RulesHelper
public const REQUIRED_EMAIL = ['email' , 'required', 'max:100'];
+ public const OPTIONAL_TEXT_MEDIUM_LENGTH = ['string', 'max:2000', 'nullable'];
}
diff --git a/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php b/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php
new file mode 100644
index 0000000000..6ad2a1dea1
--- /dev/null
+++ b/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php
@@ -0,0 +1,21 @@
+text('notes')->nullable();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('attendees', static function (Blueprint $table) {
+ $table->dropColumn('notes');
+ });
+ }
+};
diff --git a/docker/all-in-one/.env b/docker/all-in-one/.env
index 72fd12f582..a76047ce19 100644
--- a/docker/all-in-one/.env
+++ b/docker/all-in-one/.env
@@ -1,12 +1,17 @@
-# See the README.md for informaiton on how to generate the JWT_SECRET and APP_KEY
+# See the README.md file for informaiton on how to generate the JWT_SECRET and APP_KEY
APP_KEY=
JWT_SECRET=
+# Frontend variables (Always prefixed with VITE_)
VITE_FRONTEND_URL=http://localhost:8123
VITE_API_URL_CLIENT=http://localhost:8123/api
VITE_API_URL_SERVER=http://localhost:80/api
VITE_STRIPE_PUBLISHABLE_KEY=pk_test
+# Backend variables
+# These values may not be suitable for production environments.
+# Please refer to the documentation for more information on how to configure these values
+# https://hi.events/docs/getting-started/deploying
LOG_CHANNEL=stderr
QUEUE_CONNECTION=sync
MAIL_MAILER=log
@@ -15,5 +20,6 @@ FILESYSTEM_PUBLIC_DISK=public
FILESYSTEM_PRIVATE_DISK=local
APP_CDN_URL=http://localhost:8123/storage
+APP_FRONTEND_URL=http://localhost:8123
DATABASE_URL=postgresql://postgres:secret@postgres:5432/hi-events
diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml
index 68c5a79c45..90f4528561 100644
--- a/docker/all-in-one/docker-compose.yml
+++ b/docker/all-in-one/docker-compose.yml
@@ -15,6 +15,7 @@ services:
- QUEUE_CONNECTION=${QUEUE_CONNECTION}
- MAIL_MAILER=${MAIL_MAILER}
- APP_KEY=${APP_KEY}
+ - APP_FRONTEND_URL=${APP_FRONTEND_URL}
- JWT_SECRET=${JWT_SECRET}
- FILESYSTEM_PUBLIC_DISK=${FILESYSTEM_PUBLIC_DISK}
- FILESYSTEM_PRIVATE_DISK=${FILESYSTEM_PRIVATE_DISK}
diff --git a/frontend/src/api/attendee.client.ts b/frontend/src/api/attendee.client.ts
index 6f09fcbe9e..ba44fb839d 100644
--- a/frontend/src/api/attendee.client.ts
+++ b/frontend/src/api/attendee.client.ts
@@ -8,6 +8,7 @@ export interface EditAttendeeRequest {
first_name: string;
last_name: string;
email: string;
+ notes?: string;
product_id?: IdParam;
product_price_id?: IdParam;
status?: string;
diff --git a/frontend/src/components/common/AttendeeDetails/index.tsx b/frontend/src/components/common/AttendeeDetails/index.tsx
index 57066d30f5..0ecc72de23 100644
--- a/frontend/src/components/common/AttendeeDetails/index.tsx
+++ b/frontend/src/components/common/AttendeeDetails/index.tsx
@@ -7,7 +7,7 @@ import {getLocaleName, SupportedLocales} from "../../../locales.ts";
export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => {
return (
-
+
{t`Name`}
@@ -29,7 +29,7 @@ export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => {
{t`Status`}
- {attendee.status}
+ {attendee.status === 'ACTIVE' ? {t`Active`} : {t`Canceled`}}
diff --git a/frontend/src/components/common/AttendeeTable/index.tsx b/frontend/src/components/common/AttendeeTable/index.tsx
index 5fd2f12b31..acc6b6b8e7 100644
--- a/frontend/src/components/common/AttendeeTable/index.tsx
+++ b/frontend/src/components/common/AttendeeTable/index.tsx
@@ -1,6 +1,6 @@
import {Anchor, Avatar, Badge, Button, Table as MantineTable,} from '@mantine/core';
import {Attendee, MessageType} from "../../../types.ts";
-import {IconEye, IconMailForward, IconPencil, IconPlus, IconSend, IconTrash} from "@tabler/icons-react";
+import {IconMailForward, IconPlus, IconSend, IconTrash, IconUserCog} from "@tabler/icons-react";
import {getInitials, getProductFromEvent} from "../../../utilites/helpers.ts";
import {Table, TableHead} from "../Table";
import {useDisclosure} from "@mantine/hooks";
@@ -8,7 +8,6 @@ import {SendMessageModal} from "../../modals/SendMessageModal";
import {useState} from "react";
import {NoResultsSplash} from "../NoResultsSplash";
import {useParams} from "react-router-dom";
-import {EditAttendeeModal} from "../../modals/EditAttendeeModal";
import {useGetEvent} from "../../../queries/useGetEvent.ts";
import Truncate from "../Truncate";
import {notifications} from "@mantine/notifications";
@@ -17,7 +16,7 @@ import {showError, showSuccess} from "../../../utilites/notifications.tsx";
import {t, Trans} from "@lingui/macro";
import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx";
import {useResendAttendeeTicket} from "../../../mutations/useResendAttendeeTicket.ts";
-import {ViewAttendeeModal} from "../../modals/ViewAttendeeModal";
+import {ManageAttendeeModal} from "../../modals/ManageAttendeeModal";
import {ActionMenu} from '../ActionMenu';
interface AttendeeTableProps {
@@ -28,7 +27,6 @@ interface AttendeeTableProps {
export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) => {
const {eventId} = useParams();
const [isMessageModalOpen, messageModal] = useDisclosure(false);
- const [isEditModalOpen, editModal] = useDisclosure(false);
const [isViewModalOpem, viewModalOpen] = useDisclosure(false);
const [selectedAttendee, setSelectedAttendee] = useState
();
const {data: event} = useGetEvent(eventId);
@@ -161,8 +159,8 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
label: t`Actions`,
items: [
{
- label: t`View attendee`,
- icon: ,
+ label: t`Manage attendee`,
+ icon: ,
onClick: () => handleModalClick(attendee, viewModalOpen),
},
{
@@ -170,11 +168,6 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
icon: ,
onClick: () => handleModalClick(attendee, messageModal),
},
- {
- label: t`Edit attendee`,
- icon: ,
- onClick: () => handleModalClick(attendee, editModal),
- },
{
label: t`Resend ticket email`,
icon: ,
@@ -207,12 +200,7 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
attendeeId={selectedAttendee.id}
messageType={MessageType.Attendee}
/>}
- {(selectedAttendee?.id && isEditModalOpen) && }
-
- {(selectedAttendee?.id && isViewModalOpem) && }
diff --git a/frontend/src/components/forms/ProductCategoryForm/index.tsx b/frontend/src/components/forms/ProductCategoryForm/index.tsx
index 49d40549e8..b26a93eaa4 100644
--- a/frontend/src/components/forms/ProductCategoryForm/index.tsx
+++ b/frontend/src/components/forms/ProductCategoryForm/index.tsx
@@ -21,7 +21,6 @@ export const ProductCategoryForm = ({form}: ProductCategoryFormProps) => {
diff --git a/frontend/src/components/modals/EditAttendeeModal/index.tsx b/frontend/src/components/modals/EditAttendeeModal/index.tsx
deleted file mode 100644
index 9e67208e5d..0000000000
--- a/frontend/src/components/modals/EditAttendeeModal/index.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import {Modal} from "../../common/Modal";
-import {GenericModalProps, ProductCategory, ProductType} from "../../../types.ts";
-import {Button} from "../../common/Button";
-import {useParams} from "react-router-dom";
-import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx";
-import {useForm} from "@mantine/form";
-import {LoadingOverlay, TextInput} from "@mantine/core";
-import {EditAttendeeRequest} from "../../../api/attendee.client.ts";
-import {useGetAttendee} from "../../../queries/useGetAttendee.ts";
-import {useEffect} from "react";
-import {useUpdateAttendee} from "../../../mutations/useUpdateAttendee.ts";
-import {showSuccess} from "../../../utilites/notifications.tsx";
-import {useGetEvent} from "../../../queries/useGetEvent.ts";
-import {t} from "@lingui/macro";
-import {InputGroup} from "../../common/InputGroup";
-import {ProductSelector} from "../../common/ProductSelector";
-
-interface EditAttendeeModalProps extends GenericModalProps {
- attendeeId: number;
-}
-
-export const EditAttendeeModal = ({onClose, attendeeId}: EditAttendeeModalProps) => {
- const {eventId} = useParams();
- const errorHandler = useFormErrorResponseHandler();
- const {data: attendee, isFetched} = useGetAttendee(eventId, attendeeId);
- const {data: event} = useGetEvent(eventId);
- const mutation = useUpdateAttendee();
- const form = useForm({
- initialValues: {
- first_name: '',
- last_name: '',
- email: '',
- product_id: '',
- product_price_id: '',
- },
- });
-
- useEffect(() => {
- if (!attendee) {
- return;
- }
-
- form.setValues({
- first_name: attendee.first_name,
- last_name: attendee.last_name,
- email: attendee.email,
- product_id: String(attendee.product_id),
- product_price_id: String(attendee.product_price_id),
- });
-
- }, [isFetched]);
-
- const handleSubmit = (values: EditAttendeeRequest) => {
- mutation.mutate({
- attendeeId: attendeeId,
- eventId: eventId,
- attendeeData: values,
- }, {
- onSuccess: () => {
- showSuccess(t`Successfully updated attendee`);
- onClose();
- },
- onError: (error) => errorHandler(form, error),
- })
- };
-
- if (!isFetched) {
- return
- }
-
- return (
-
-
-
- );
-}
diff --git a/frontend/src/components/modals/ViewAttendeeModal/ViewAttendeeModal.module.scss b/frontend/src/components/modals/ManageAttendeeModal/ManageAttendeeModal.module.scss
similarity index 100%
rename from frontend/src/components/modals/ViewAttendeeModal/ViewAttendeeModal.module.scss
rename to frontend/src/components/modals/ManageAttendeeModal/ManageAttendeeModal.module.scss
diff --git a/frontend/src/components/modals/ManageAttendeeModal/index.tsx b/frontend/src/components/modals/ManageAttendeeModal/index.tsx
new file mode 100644
index 0000000000..d45b9bd88a
--- /dev/null
+++ b/frontend/src/components/modals/ManageAttendeeModal/index.tsx
@@ -0,0 +1,225 @@
+import {useParams} from "react-router-dom";
+import {useGetAttendee} from "../../../queries/useGetAttendee.ts";
+import {useGetEvent} from "../../../queries/useGetEvent.ts";
+import {useGetOrder} from "../../../queries/useGetOrder.ts";
+import {useUpdateAttendee} from "../../../mutations/useUpdateAttendee.ts";
+import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx";
+import {useForm} from "@mantine/form";
+import {Modal} from "../../common/Modal";
+import {Accordion} from "../../common/Accordion";
+import {Button} from "../../common/Button";
+import {Avatar, Badge, Box, Group, Stack, Tabs, Text, Textarea, TextInput} from "@mantine/core";
+import {IconEdit, IconNotebook, IconQuestionMark, IconReceipt, IconTicket, IconUser} from "@tabler/icons-react";
+import {LoadingMask} from "../../common/LoadingMask";
+import {AttendeeDetails} from "../../common/AttendeeDetails";
+import {OrderDetails} from "../../common/OrderDetails";
+import {QuestionAndAnswerList} from "../../common/QuestionAndAnswerList";
+import {AttendeeProduct} from "../../common/AttendeeProduct";
+import {getInitials} from "../../../utilites/helpers.ts";
+import {t} from "@lingui/macro";
+import classes from './ManageAttendeeModal.module.scss';
+import {useEffect, useState} from "react";
+import {showSuccess} from "../../../utilites/notifications.tsx";
+import {ProductSelector} from "../../common/ProductSelector";
+import {GenericModalProps, IdParam, ProductCategory, ProductType, QuestionAnswer} from "../../../types.ts";
+import {InputGroup} from "../../common/InputGroup";
+import {InputLabelWithHelp} from "../../common/InputLabelWithHelp";
+import {EditAttendeeRequest} from "../../../api/attendee.client.ts";
+
+interface ManageAttendeeModalProps extends GenericModalProps {
+ onClose: () => void;
+ attendeeId: IdParam;
+}
+
+export const ManageAttendeeModal = ({onClose, attendeeId}: ManageAttendeeModalProps) => {
+ const {eventId} = useParams();
+ const {data: attendee} = useGetAttendee(eventId, attendeeId);
+ const {data: order} = useGetOrder(eventId, attendee?.order_id);
+ const {data: event} = useGetEvent(eventId);
+ const errorHandler = useFormErrorResponseHandler();
+ const mutation = useUpdateAttendee();
+
+ const form = useForm({
+ initialValues: {
+ first_name: "",
+ last_name: "",
+ email: "",
+ notes: "",
+ product_id: "",
+ product_price_id: "",
+ },
+ });
+
+ const [activeTab, setActiveTab] = useState("view");
+
+ useEffect(() => {
+ if (attendee) {
+ form.initialize({
+ first_name: attendee.first_name,
+ last_name: attendee.last_name,
+ email: attendee.email,
+ notes: attendee.notes || "",
+ product_id: String(attendee.product_id),
+ product_price_id: String(attendee.product_price_id),
+ });
+ }
+ }, [attendee]);
+
+ if (!attendee || !order || !event) {
+ return ;
+ }
+
+ const handleSubmit = (values: EditAttendeeRequest) => {
+ mutation.mutate(
+ {
+ attendeeId,
+ eventId,
+ attendeeData: values,
+ },
+ {
+ onSuccess: () => {
+ showSuccess(t`Successfully updated attendee`);
+ setActiveTab("view");
+ },
+ onError: (error) => errorHandler(form, error),
+ }
+ );
+ };
+
+ const fullName = `${attendee.first_name} ${attendee.last_name}`;
+ const hasQuestions = attendee.question_answers && attendee.question_answers.length > 0;
+
+ const detailsTab = (
+
+
+
+
+
+
+
+ {event?.product_categories && event.product_categories.length > 0 && (
+
+ )}
+
+
}
+ {...form.getInputProps("notes")}
+ placeholder={t`Add any notes about the attendee...`}
+ minRows={3}
+ maxRows={6}
+ autosize
+ />
+
+ );
+
+ const viewContent = (
+ ,
+ },
+ {
+ value: "notes",
+ icon: IconNotebook,
+ title: t`Attendee Notes`,
+ hidden: !attendee.notes,
+ content: (
+
+
+ {attendee.notes}
+
+
+ ),
+ },
+ {
+ value: "order",
+ icon: IconReceipt,
+ title: t`Order Details`,
+ content: ,
+ },
+ {
+ value: "ticket",
+ icon: IconTicket,
+ title: t`Attendee Ticket`,
+ content: attendee.product ? (
+
+ ) : (
+
+ {t`No product associated with this attendee.`}
+
+ ),
+ },
+ {
+ value: "questions",
+ icon: IconQuestionMark,
+ title: t`Questions & Answers`,
+ count: hasQuestions ? attendee?.question_answers?.length : undefined,
+ content: hasQuestions ? (
+
+ ) : (
+
+ {t`No questions answered by this attendee.`}
+
+ ),
+ },
+ ].filter(item => !item.hidden)}
+ defaultValue="details"
+ />
+ );
+
+ return (
+
+
+
+
+
+
+ {getInitials(fullName)}
+
+
+ {fullName}
+
+ {attendee.status}
+
+
+
+
+
+
+
+ }>{t`View`}
+ }>{t`Edit`}
+
+
+
+ {viewContent}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/modals/ViewAttendeeModal/index.tsx b/frontend/src/components/modals/ViewAttendeeModal/index.tsx
deleted file mode 100644
index 1338577c0d..0000000000
--- a/frontend/src/components/modals/ViewAttendeeModal/index.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import {useParams} from "react-router-dom";
-import {useGetAttendee} from "../../../queries/useGetAttendee.ts";
-import {Modal} from "../../common/Modal";
-import {t} from "@lingui/macro";
-import {GenericModalProps, IdParam, QuestionAnswer} from "../../../types.ts";
-import {OrderDetails} from "../../common/OrderDetails";
-import {useGetEvent} from "../../../queries/useGetEvent.ts";
-import {useGetOrder} from "../../../queries/useGetOrder.ts";
-import {AttendeeDetails} from "../../common/AttendeeDetails";
-import {QuestionAndAnswerList} from "../../common/QuestionAndAnswerList";
-import {LoadingMask} from "../../common/LoadingMask";
-import {AttendeeProduct} from "../../common/AttendeeProduct";
-import {Avatar, Group, Stack, Text} from "@mantine/core";
-import {IconQuestionMark, IconReceipt, IconTicket, IconUser} from "@tabler/icons-react";
-import {getInitials} from "../../../utilites/helpers.ts";
-import {Accordion, AccordionItem} from "../../common/Accordion";
-import classes from './ViewAttendeeModal.module.scss';
-
-interface ViewAttendeeModalProps extends GenericModalProps {
- onClose: () => void;
- attendeeId: IdParam;
-}
-
-export const ViewAttendeeModal = ({onClose, attendeeId}: ViewAttendeeModalProps) => {
- const {eventId} = useParams();
- const {data: attendee} = useGetAttendee(eventId, attendeeId);
- const {data: order} = useGetOrder(eventId, attendee?.order_id);
- const {data: event} = useGetEvent(eventId);
-
- if (!attendee || !order || !event) {
- return ;
- }
-
- const fullName = `${attendee.first_name} ${attendee.last_name}`;
- const hasQuestions = attendee.question_answers && attendee.question_answers.length > 0;
-
- const accordionItems: AccordionItem[] = [
- {
- value: 'details',
- icon: IconUser,
- title: t`Attendee Details`,
- content:
- },
- {
- value: 'order',
- icon: IconReceipt,
- title: t`Order Details`,
- content:
- },
- {
- value: 'ticket',
- icon: IconTicket,
- title: t`Attendee Ticket`,
- content: attendee.product ? (
-
- ) : (
-
- {t`No product associated with this attendee.`}
-
- )
- },
- {
- value: 'questions',
- icon: IconQuestionMark,
- title: t`Questions & Answers`,
- count: hasQuestions ? attendee?.question_answers?.length : undefined,
- content: hasQuestions ? (
-
- ) : (
-
- {t`No questions have been answered by this attendee.`}
-
- )
- }
- ];
-
- return (
-
-
-
-
-
- {getInitials(fullName)}
-
-
- Attendee
- {fullName}
-
-
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx
index 9b74114724..e2df774207 100644
--- a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx
+++ b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx
@@ -21,7 +21,7 @@ import {
ProductPriceQuantityFormValue
} from "../../../../api/order.client.ts";
import {useForm} from "@mantine/form";
-import {range, useDisclosure, useInputState, useResizeObserver} from "@mantine/hooks";
+import {range, useInputState, useResizeObserver} from "@mantine/hooks";
import React, {useEffect, useMemo, useRef, useState} from "react";
import {showError, showInfo, showSuccess} from "../../../../utilites/notifications.tsx";
import {addQueryStringToUrl, isObjectEmpty, removeQueryStringFromUrl} from "../../../../utilites/helpers.ts";
@@ -81,6 +81,7 @@ const SelectProducts = (props: SelectProductsProps) => {
const [event, setEvent] = useState(props.event);
const [orderInProcessOverlayVisible, setOrderInProcessOverlayVisible] = useState(false);
const [resizeRef, resizeObserverRect] = useResizeObserver();
+ const [collapsedProducts, setCollapsedProducts] = useState<{ [key: number]: boolean }>({});
useEffect(() => sendHeightToIframeWidgets(), [resizeObserverRect.height]);
@@ -318,15 +319,20 @@ const SelectProducts = (props: SelectProductsProps) => {
.map((n) => n.toString());
quantityRange.unshift("0");
-
- const [productIsCollapsed, {toggle: collapseProduct}] = useDisclosure(product.start_collapsed);
+ const isProductCollapsed = collapsedProducts[Number(product.id)] ?? product.start_collapsed;
+ const toggleCollapse = () => {
+ setCollapsedProducts(prev => ({
+ ...prev,
+ [Number(product.id)]: !isProductCollapsed
+ }));
+ };
return (
{product.title}
@@ -348,17 +354,19 @@ const SelectProducts = (props: SelectProductsProps) => {
)}
{(!product.is_available && product.type === 'TIERED') && (
-
+
)}
+ className={isProductCollapsed ? "" : "open"}/>
-
+
{
{product.max_per_order && form.values.products && isObjectEmpty(form.errors) && (form.values.products[productIndex]?.quantities.reduce((acc, {quantity}) => acc + Number(quantity), 0) > product.max_per_order) && (
- The maximum number of products for {product.title}
+ The maximum number of products
+ for {product.title}
is {product.max_per_order}
)}
@@ -384,7 +393,8 @@ const SelectProducts = (props: SelectProductsProps) => {
{product.description && (
-
+
diff --git a/frontend/src/mutations/useUpdateAttendee.ts b/frontend/src/mutations/useUpdateAttendee.ts
index 2e430dfe2f..f762c8e0bf 100644
--- a/frontend/src/mutations/useUpdateAttendee.ts
+++ b/frontend/src/mutations/useUpdateAttendee.ts
@@ -2,6 +2,7 @@ import {useMutation, useQueryClient} from "@tanstack/react-query";
import {attendeesClient, EditAttendeeRequest} from "../api/attendee.client.ts";
import {IdParam} from "../types.ts";
import {GET_ATTENDEES_QUERY_KEY} from "../queries/useGetAttendees.ts";
+import {GET_ATTENDEE_QUERY_KEY} from "../queries/useGetAttendee.ts";
export const useUpdateAttendee = () => {
const queryClient = useQueryClient();
@@ -13,6 +14,13 @@ export const useUpdateAttendee = () => {
attendeeData: EditAttendeeRequest,
}) => attendeesClient.update(eventId, attendeeId, attendeeData),
- onSuccess: () => queryClient.invalidateQueries({queryKey: [GET_ATTENDEES_QUERY_KEY]})
+ onSuccess: (_data, variables) => {
+ return Promise.all([
+ queryClient.invalidateQueries({queryKey: [GET_ATTENDEES_QUERY_KEY]}),
+ queryClient.invalidateQueries({
+ queryKey: [GET_ATTENDEE_QUERY_KEY, variables.eventId, variables.attendeeId]
+ })
+ ]);
+ }
});
-}
\ No newline at end of file
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 32e874e9fe..0a9eca13aa 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -360,6 +360,7 @@ export interface Attendee {
first_name: string;
last_name: string;
email: string;
+ notes?: string;
order?: Order;
public_id: string;
short_id: string;