diff --git a/buzz/api/checkin/__init__.py b/buzz/api/checkin/__init__.py index 54be4d61..976327d1 100644 --- a/buzz/api/checkin/__init__.py +++ b/buzz/api/checkin/__init__.py @@ -1,7 +1,9 @@ import frappe -from buzz.api.checkin.schemas import CheckinResponse -from buzz.api.checkin.services import CheckinService +from buzz.api.checkin.schemas import CheckinEvent, CheckinResponse +from buzz.api.checkin.services import CheckinService, checkin_events, ensure_can_check_in +from buzz.api.events.schemas import EventGuestsResponse +from buzz.api.events.services import GUESTS_PAGE_SIZE, event_guests @frappe.whitelist() @@ -9,6 +11,29 @@ def validate_ticket_for_checkin(ticket_id: str) -> CheckinResponse: return CheckinService(ticket_id).validate() -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def checkin_ticket(ticket_id: str) -> CheckinResponse: return CheckinService(ticket_id).checkin() + + +@frappe.whitelist(methods=["POST"]) +def undo_checkin(ticket_id: str) -> CheckinResponse: + return CheckinService(ticket_id).undo_checkin() + + +@frappe.whitelist() +def get_checkin_events() -> list[CheckinEvent]: + return checkin_events() + + +@frappe.whitelist() +def get_checkin_guests( + event: str, + search: str | None = None, + checked_in: str | None = None, + start: int = 0, + limit: int = GUESTS_PAGE_SIZE, +) -> EventGuestsResponse: + """The guest list, for someone who can check its guests in.""" + ensure_can_check_in(event) + return event_guests(event, search, start=start, limit=limit, checked_in=checked_in) diff --git a/buzz/api/checkin/exceptions.py b/buzz/api/checkin/exceptions.py index ffa278bd..32c8aab7 100644 --- a/buzz/api/checkin/exceptions.py +++ b/buzz/api/checkin/exceptions.py @@ -21,3 +21,8 @@ class TicketRefunded(Conflict): class AlreadyCheckedIn(Conflict): title = _lt("Already Checked In") message = _lt("This ticket was already checked in today, at {checked_in_at}.") + + +class NotCheckedIn(Conflict): + title = _lt("Not Checked In") + message = _lt("This ticket has not been checked in today.") diff --git a/buzz/api/checkin/schemas.py b/buzz/api/checkin/schemas.py index e875bc41..128f4049 100644 --- a/buzz/api/checkin/schemas.py +++ b/buzz/api/checkin/schemas.py @@ -40,3 +40,12 @@ class CheckinResponse(APIResponse): message: str ticket: TicketCheckinDetails payment_details: PaymentDetails | None = None + + +class CheckinEvent(APIResponse): + name: str + title: str + start_date: date | None + start_time: timedelta | None + end_date: date | None + end_time: timedelta | None diff --git a/buzz/api/checkin/services.py b/buzz/api/checkin/services.py index 1bce4694..c7db20e5 100644 --- a/buzz/api/checkin/services.py +++ b/buzz/api/checkin/services.py @@ -6,18 +6,20 @@ from buzz.api.checkin.exceptions import ( AlreadyCheckedIn, + NotCheckedIn, TicketCancelled, TicketNotFound, TicketRefunded, ) from buzz.api.checkin.schemas import ( + CheckinEvent, CheckinResponse, PaymentDetails, TicketAddOn, TicketCheckinDetails, ) from buzz.api.exceptions import NotPermitted -from buzz.permissions import has_team_access +from buzz.permissions import CHECKIN_ROLES, can_check_in, is_unrestricted from buzz.ticketing.doctype.event_booking_refund.event_booking_refund import get_committed_tickets if TYPE_CHECKING: @@ -28,19 +30,45 @@ CANCELLED = 2 +def ensure_can_check_in(event: str) -> None: + team = frappe.db.get_value("Buzz Event", event, "team") + if not can_check_in(team, frappe.session.user): + NotPermitted.throw() + + +def checkin_events() -> list[CheckinEvent]: + """Events not yet over, on the teams the user works the front desk for.""" + filters = {"is_published": 1, "end_date": [">=", today()]} + user = frappe.session.user + if not is_unrestricted(user): + teams = frappe.get_all( + "Buzz Team Membership", + filters={"user": user, "enabled": 1, "team_role": ["in", list(CHECKIN_ROLES)]}, + pluck="team", + ) + filters["team"] = ["in", teams or [""]] + + rows = frappe.get_all( + "Buzz Event", + filters=filters, + fields=["name", "title", "start_date", "start_time", "end_date", "end_time"], + order_by="start_date asc", + ignore_permissions=True, + ) + return [CheckinEvent(**{**row, "name": str(row.name)}) for row in rows] + + class CheckinService: - """Front-desk check-in for a single ticket. Restricted to Frontdesk Manager.""" + """Front-desk check-in for a single ticket, by a team member at Frontdesk or above.""" def __init__(self, ticket_id: str): - frappe.only_for("Frontdesk Manager", True) if not frappe.db.exists("Event Ticket", ticket_id): TicketNotFound.throw() self.ticket_id = ticket_id self.date = today() # Both endpoints route through here, so one team check covers them. - if not has_team_access(self.event.team, "read", frappe.session.user): - NotPermitted.throw() + ensure_can_check_in(self.ticket.event) @property def ticket(self) -> "EventTicket": @@ -83,11 +111,32 @@ def ensure_checkin_allowed(self) -> None: if self.ticket.booking and self.ticket_id in get_committed_tickets(self.ticket.booking): TicketRefunded.throw() - existing = frappe.db.exists("Event Check In", {"ticket": self.ticket_id, "date": self.date}) + existing = self.todays_checkin() if existing: creation = frappe.db.get_value("Event Check In", existing, "creation") AlreadyCheckedIn.throw(checked_in_at=f"{format_date(creation)} at {format_time(creation)}") + def undo_checkin(self) -> CheckinResponse: + existing = self.todays_checkin() + if not existing: + NotCheckedIn.throw() + + # Cancelled rather than deleted, so the record of the mistaken check-in stays. + checkin_doc = frappe.get_doc("Event Check In", existing) + checkin_doc.flags.ignore_permissions = True + checkin_doc.cancel() + return CheckinResponse( + message=_("Undid the check-in for {attendee_name}").format( + attendee_name=self.ticket.attendee_name + ), + ticket=self.ticket_details(), + ) + + def todays_checkin(self) -> str | None: + return frappe.db.exists( + "Event Check In", {"ticket": self.ticket_id, "date": self.date, "docstatus": 1} + ) + def record_checkin(self) -> "EventCheckIn": checkin_doc = frappe.new_doc("Event Check In") checkin_doc.ticket = self.ticket_id diff --git a/buzz/api/checkin/test_checkin.py b/buzz/api/checkin/test_checkin.py index dcefacde..13848004 100644 --- a/buzz/api/checkin/test_checkin.py +++ b/buzz/api/checkin/test_checkin.py @@ -1,13 +1,22 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.checkin import checkin_ticket, validate_ticket_for_checkin +from buzz.api.checkin import ( + checkin_ticket, + get_checkin_events, + get_checkin_guests, + validate_ticket_for_checkin, +) from buzz.api.checkin.exceptions import AlreadyCheckedIn, TicketCancelled, TicketNotFound +from buzz.api.exceptions import NotPermitted from buzz.tests.factories import ( BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, EventBookingFactory, EventPaymentFactory, EventTicketFactory, + UserFactory, ) @@ -86,3 +95,74 @@ def test_validation_rejects_an_already_checked_in_ticket(self): with self.assertRaises(AlreadyCheckedIn): validate_ticket_for_checkin(self.ticket) + + +class TestCheckinAccess(IntegrationTestCase): + """Front-desk work is for the event's own team, at Frontdesk or above.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + owner = UserFactory.create_once("checkin-owner@example.com").name + other_owner = UserFactory.create_once("checkin-other-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(owner).name + cls.other_team = BuzzTeamFactory.create_owned_by(other_owner).name + cls.event = str(BuzzEventFactory.create(team=cls.team).name) + cls.other_event = str(BuzzEventFactory.create(team=cls.other_team).name) + + cls.frontdesk = cls.member("checkin-frontdesk@example.com", "Frontdesk") + cls.manager = cls.member("checkin-manager@example.com", "Manager") + cls.viewer = cls.member("checkin-viewer@example.com", "Viewer") + # Front desk on one team, only a Viewer on the other. + BuzzTeamMembershipFactory.create(team=cls.other_team, user=cls.frontdesk, team_role="Viewer") + + @classmethod + def member(cls, email: str, team_role: str) -> str: + user = UserFactory.create_once(email).name + BuzzTeamMembershipFactory.create(team=cls.team, user=user, team_role=team_role) + return user + + def setUp(self): + frappe.clear_messages() + + def test_frontdesk_and_manager_can_check_in(self): + for user in (self.frontdesk, self.manager): + ticket = EventTicketFactory.create(event=self.event).name + with self.set_user(user): + self.assertTrue(checkin_ticket(ticket).ticket.is_checked_in) + + def test_viewer_cannot_check_in(self): + ticket = EventTicketFactory.create(event=self.event).name + with self.set_user(self.viewer), self.assertRaises(NotPermitted): + validate_ticket_for_checkin(ticket) + + def test_frontdesk_role_does_not_carry_to_another_team(self): + ticket = EventTicketFactory.create(event=self.other_event).name + with self.set_user(self.frontdesk), self.assertRaises(NotPermitted): + checkin_ticket(ticket) + + def test_events_list_only_the_front_desk_teams(self): + with self.set_user(self.frontdesk): + events = {event.name for event in get_checkin_events()} + + self.assertIn(self.event, events) + self.assertNotIn(self.other_event, events) + + def test_viewer_cannot_open_the_guest_list(self): + with self.set_user(self.viewer), self.assertRaises(NotPermitted): + get_checkin_guests(self.event) + + def test_guest_list_splits_on_todays_check_in(self): + event = str(BuzzEventFactory.create(team=self.team).name) + arrived, waiting = (EventTicketFactory.create("submitted", event=event).name for _ in range(2)) + with self.set_user(self.frontdesk): + checkin_ticket(arrived) + everyone = get_checkin_guests(event).__json__() + checked_in = get_checkin_guests(event, checked_in="1").__json__() + not_checked_in = get_checkin_guests(event, checked_in="0").__json__() + + self.assertEqual(everyone["checked_in"], 1) + self.assertEqual([guest["name"] for guest in checked_in["guests"]], [arrived]) + self.assertIsNotNone(checked_in["guests"][0]["checked_in_at"]) + self.assertEqual([guest["name"] for guest in not_checked_in["guests"]], [waiting]) + self.assertEqual(not_checked_in["matched"], 1) diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index 17bf7880..c0429f94 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -103,6 +103,7 @@ class EventGuest(APIResponse): # When the ticket was raised, which is when this guest registered. registered_at: datetime | None = None add_ons: list[GuestAddOn] = Field(default_factory=list) + checked_in_at: datetime | None = None class GuestTicketType(APIResponse): @@ -119,10 +120,13 @@ class EventGuestsResponse(APIResponse): start_time: timedelta | None = None end_date: date | None = None venue: str | None = None + route: str | None = None # Everyone registered, then everyone the current search matches — the second is the # first when nothing is being searched for. total: int matched: int + # Guests checked in today, which is what the front desk counts against. + checked_in: int = 0 guests: list[EventGuest] has_next_page: bool = False filter_fields: list[FilterField] diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index b2870a2f..151316eb 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -1,8 +1,10 @@ +from datetime import datetime + import frappe from frappe import _ from frappe.query_builder import Case from frappe.query_builder.functions import Count, Date -from frappe.utils import add_days, getdate +from frappe.utils import add_days, getdate, today from buzz.api.booking.guests import email_otp_available, phone_otp_available from buzz.api.booking.services import are_registrations_closed @@ -280,7 +282,7 @@ def meeting_link_of(row) -> str | None: def guest_search_filters(search: str | None) -> list[list] | None: - """Name or email, matched loosely — the two things a manager reads off the row.""" + """Name, email or ticket ID, matched loosely.""" term = (search or "").strip() if not term: return None @@ -288,6 +290,7 @@ def guest_search_filters(search: str | None) -> list[list] | None: return [ ["attendee_name", "like", f"%{term}%"], ["attendee_email", "like", f"%{term}%"], + ["name", "like", f"%{term}%"], ] @@ -346,6 +349,7 @@ def event_guests( order: str = "desc", start: int = 0, limit: int = GUESTS_PAGE_SIZE, + checked_in: str | None = None, ) -> EventGuestsResponse: """One page of the people holding a ticket to an event. @@ -353,12 +357,17 @@ def event_guests( access is the bar: this shows the team what it already sees through the doctype. Ordered by when the ticket was registered, newest first by default, so a page is a - stable slice as the caller walks down the list. + stable slice as the caller walks down the list. `checked_in` is "1" or "0" to keep only + the guests who have, or have not, checked in today. """ ensure_event_team_access(event) filter_fields, answer_parents = guest_filter_fields(event) conditions = ListConditions("Event Ticket", filter_fields, answer_parents).frappe_filters(filters) + arrivals = checked_in_today(event) + if checked_in in ("0", "1"): + operator = "in" if checked_in == "1" else "not in" + conditions.append(["name", operator, list(arrivals) or [""]]) query_filters = [["event", "=", event], ["docstatus", "=", 1], *conditions] or_filters = guest_search_filters(search) limit = max(1, min(int(limit), 100)) @@ -385,7 +394,10 @@ def event_guests( ticket["registered_at"] = ticket.pop("creation") ticket["ticket_type"] = type_titles.get(ticket.ticket_type) or ticket.ticket_type - guests = [EventGuest(**ticket, add_ons=by_ticket.get(ticket.name, [])) for ticket in tickets] + guests = [ + EventGuest(**ticket, add_ons=by_ticket.get(ticket.name, []), checked_in_at=arrivals.get(ticket.name)) + for ticket in tickets + ] doc = frappe.get_cached_doc("Buzz Event", event) total = count_tickets({"event": event, "docstatus": 1}) matched = count_tickets(query_filters, or_filters) if or_filters or conditions else total @@ -395,14 +407,35 @@ def event_guests( start_time=doc.start_time, end_date=doc.end_date, venue=doc.get_venue_name(), + route=doc.route if doc.is_published else None, total=total, matched=matched, + checked_in=len(arrivals), guests=guests, has_next_page=start + len(guests) < matched, filter_fields=filter_fields, ) +def checked_in_today(event: str) -> dict[str, datetime]: + """When each of the event's submitted tickets checked in today, keyed by ticket.""" + # Query builder: the team check in the caller is the authorization. + checkin, ticket = frappe.qb.DocType("Event Check In"), frappe.qb.DocType("Event Ticket") + rows = ( + frappe.qb.from_(checkin) + .join(ticket) + .on(ticket.name == checkin.ticket) + .select(checkin.ticket, checkin.creation) + .where( + (checkin.event == event) + & (checkin.date == today()) + & (checkin.docstatus == 1) + & (ticket.docstatus == 1) + ) + ).run(as_dict=True) + return {row.ticket: row.creation for row in rows} + + def guest_filter_fields(event: str) -> tuple[list[FilterField], dict]: """Ticket type, then the event's ticket and booking questions. diff --git a/buzz/permissions.py b/buzz/permissions.py index 9110ce97..3e19ec7e 100644 --- a/buzz/permissions.py +++ b/buzz/permissions.py @@ -7,6 +7,7 @@ WRITE_ROLES = frozenset({"Owner", "Admin", "Manager"}) ADMIN_ROLES = frozenset({"Owner", "Admin"}) +CHECKIN_ROLES = frozenset({"Owner", "Admin", "Manager", "Frontdesk"}) WRITE_PTYPES = frozenset({"write", "create", "submit", "cancel", "amend", "delete"}) # Attendees, sponsors and speakers reach their own rows without any membership. @@ -130,6 +131,11 @@ def has_team_access(team: str | None, ptype: str, user: str) -> bool: return bool(team_role) and role_allows(team_role, ptype) +def can_check_in(team: str | None, user: str) -> bool: + """Front-desk work on a team's events. Unlike has_team_access, an unstamped event fails closed.""" + return is_unrestricted(user) or team_role_of(user, team) in CHECKIN_ROLES + + def team_query_conditions(user: str | None = None, doctype: str | None = None, **kwargs) -> str | None: user = user or frappe.session.user if is_unrestricted(user): diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index f9917f1c..4f50ea17 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -33,6 +33,8 @@ declare module 'vue' { CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default'] CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default'] ChangeRoleDialog: typeof import('./src/components/dashboard/teams/ChangeRoleDialog.vue')['default'] + CheckInGuestList: typeof import('./src/components/checkin/CheckInGuestList.vue')['default'] + CheckInGuestRow: typeof import('./src/components/checkin/CheckInGuestRow.vue')['default'] CheckInQrDialog: typeof import('./src/components/dashboard/tickets/CheckInQrDialog.vue')['default'] ChosenLocation: typeof import('./src/components/dashboard/events/location/ChosenLocation.vue')['default'] CommunicationActions: typeof import('./src/components/dashboard/communications/CommunicationActions.vue')['default'] @@ -82,6 +84,7 @@ declare module 'vue' { FilterChip: typeof import('./src/components/common/filters/FilterChip.vue')['default'] FloatingCreateEventButton: typeof import('./src/components/dashboard/FloatingCreateEventButton.vue')['default'] FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default'] + GuestCheckInDialog: typeof import('./src/components/checkin/GuestCheckInDialog.vue')['default'] GuestInfoDrawer: typeof import('./src/components/dashboard/events/GuestInfoDrawer.vue')['default'] GuestListExport: typeof import('./src/components/dashboard/events/GuestListExport.vue')['default'] GuestRegistrationDialog: typeof import('./src/components/dashboard/events/GuestRegistrationDialog.vue')['default'] @@ -136,6 +139,7 @@ declare module 'vue' { SaveActions: typeof import('./src/components/dashboard/teams/SaveActions.vue')['default'] SectionHeader: typeof import('./src/components/common/SectionHeader.vue')['default'] SelectionBar: typeof import('./src/components/common/SelectionBar.vue')['default'] + SiteHeader: typeof import('./src/components/SiteHeader.vue')['default'] SortableList: typeof import('./src/components/dashboard/sponsorships/SortableList.vue')['default'] SponsorCard: typeof import('./src/components/dashboard/sponsorships/SponsorCard.vue')['default'] SponsorDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorDrawer.vue')['default'] @@ -161,7 +165,6 @@ declare module 'vue' { ThemeSwitcher: typeof import('./src/components/settings/ThemeSwitcher.vue')['default'] TicketBookingDetails: typeof import('./src/components/dashboard/tickets/TicketBookingDetails.vue')['default'] TicketCard: typeof import('./src/components/TicketCard.vue')['default'] - TicketDetailsModal: typeof import('./src/components/TicketDetailsModal.vue')['default'] TicketsSection: typeof import('./src/components/TicketsSection.vue')['default'] TicketTransferDialog: typeof import('./src/components/TicketTransferDialog.vue')['default'] TicketTypeDrawer: typeof import('./src/components/dashboard/ticket-types/TicketTypeDrawer.vue')['default'] diff --git a/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 b/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 new file mode 100644 index 00000000..64216964 Binary files /dev/null and b/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 differ diff --git a/dashboard/src/assets/LeagueGothic/league-gothic.css b/dashboard/src/assets/LeagueGothic/league-gothic.css new file mode 100644 index 00000000..b7ffcc3c --- /dev/null +++ b/dashboard/src/assets/LeagueGothic/league-gothic.css @@ -0,0 +1,8 @@ +/* League Gothic (SIL Open Font License), Latin subset. Display headings only. */ +@font-face { + font-family: "League Gothic"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("LeagueGothic-Regular.woff2") format("woff2"); +} diff --git a/dashboard/src/components/EventSelector.vue b/dashboard/src/components/EventSelector.vue index 89a11f7f..e1f1c080 100644 --- a/dashboard/src/components/EventSelector.vue +++ b/dashboard/src/components/EventSelector.vue @@ -48,18 +48,11 @@ diff --git a/dashboard/src/components/SiteHeader.vue b/dashboard/src/components/SiteHeader.vue new file mode 100644 index 00000000..dbc27043 --- /dev/null +++ b/dashboard/src/components/SiteHeader.vue @@ -0,0 +1,61 @@ + + + diff --git a/dashboard/src/components/TicketDetailsModal.vue b/dashboard/src/components/TicketDetailsModal.vue deleted file mode 100644 index 57886fe3..00000000 --- a/dashboard/src/components/TicketDetailsModal.vue +++ /dev/null @@ -1,188 +0,0 @@ - - - - diff --git a/dashboard/src/components/checkin/CheckInGuestList.vue b/dashboard/src/components/checkin/CheckInGuestList.vue new file mode 100644 index 00000000..90b12fd4 --- /dev/null +++ b/dashboard/src/components/checkin/CheckInGuestList.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/dashboard/src/components/checkin/CheckInGuestRow.vue b/dashboard/src/components/checkin/CheckInGuestRow.vue new file mode 100644 index 00000000..dafda196 --- /dev/null +++ b/dashboard/src/components/checkin/CheckInGuestRow.vue @@ -0,0 +1,77 @@ + + + diff --git a/dashboard/src/components/checkin/GuestCheckInDialog.vue b/dashboard/src/components/checkin/GuestCheckInDialog.vue new file mode 100644 index 00000000..cda9782c --- /dev/null +++ b/dashboard/src/components/checkin/GuestCheckInDialog.vue @@ -0,0 +1,122 @@ + + + diff --git a/dashboard/src/components/common/EmptyState.vue b/dashboard/src/components/common/EmptyState.vue index 0a527cb7..bb2a2414 100644 --- a/dashboard/src/components/common/EmptyState.vue +++ b/dashboard/src/components/common/EmptyState.vue @@ -19,5 +19,6 @@ defineProps<{

{{ title }}

{{ description }}

+
diff --git a/dashboard/src/components/dashboard/events/GuestListExport.vue b/dashboard/src/components/dashboard/events/GuestListExport.vue index 11a4992c..e76fcc8e 100644 --- a/dashboard/src/components/dashboard/events/GuestListExport.vue +++ b/dashboard/src/components/dashboard/events/GuestListExport.vue @@ -1,6 +1,7 @@ + + diff --git a/dashboard/src/pages/CheckInScanner.vue b/dashboard/src/pages/CheckInScanner.vue deleted file mode 100644 index 0ee338c2..00000000 --- a/dashboard/src/pages/CheckInScanner.vue +++ /dev/null @@ -1,143 +0,0 @@ - - - diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts index bcc9d046..b4d0c0d7 100644 --- a/dashboard/src/router.ts +++ b/dashboard/src/router.ts @@ -158,8 +158,8 @@ const routes: RouteRecordRaw[] = [ path: "/check-in/:eventName?", name: "check-in", props: true, - meta: { title: "Check In" }, - component: () => import("@/pages/CheckInScanner.vue"), + meta: { title: "Check In", siteHeader: true }, + component: () => import("@/pages/CheckIn.vue"), }, { path: "/register/:eventRoute", diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 4015ae4d..d3bdff45 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -270,6 +270,8 @@ export interface EventGuest { ticket_type: string | null registered_at: string | null add_ons: GuestAddOn[] + // Today's check-in, if the guest has arrived. + checked_in_at?: string | null } // buzz.api.events.get_event_registration_trend @@ -296,8 +298,10 @@ export interface EventGuests { start_time: string | null end_date: string | null venue: string | null + route: string | null total: number matched: number + checked_in: number guests: EventGuest[] has_next_page: boolean filter_fields: FilterField[] diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js index c25dd004..eb56922b 100644 --- a/dashboard/tailwind.config.js +++ b/dashboard/tailwind.config.js @@ -15,7 +15,11 @@ export default { "./node_modules/frappe-ui/experimental/**/*.{vue,js,ts,jsx,tsx}", ], theme: { - extend: {}, + extend: { + fontFamily: { + display: ['"League Gothic"', '"Arial Narrow"', "sans-serif"], + }, + }, }, plugins: [], } diff --git a/e2e/pages/check-in.page.ts b/e2e/pages/check-in.page.ts index edf5849a..ebca40bf 100644 --- a/e2e/pages/check-in.page.ts +++ b/e2e/pages/check-in.page.ts @@ -4,19 +4,19 @@ export class CheckInPage { private page: Page accessDeniedMessage: Locator manualTicketInput: Locator - lastScanResult: Locator ticketModal: Locator + private scanButton: Locator private checkButton: Locator private checkInButton: Locator constructor(page: Page) { this.page = page - this.accessDeniedMessage = page.getByText("Access Denied") + this.accessDeniedMessage = page.getByText("Access denied") this.manualTicketInput = page.getByPlaceholder("Enter ticket ID manually") + this.scanButton = page.getByRole("button", { name: "Scan", exact: true }) this.checkButton = page.getByRole("button", { name: "Check", exact: true }) this.ticketModal = page.getByRole("dialog") - this.checkInButton = page.getByRole("button", { name: "Check In", exact: true }) - this.lastScanResult = page.getByText("Last Scan Result") + this.checkInButton = page.getByRole("button", { name: "Check in", exact: true }) } async goto(eventName?: string): Promise { @@ -29,20 +29,18 @@ export class CheckInPage { await this.checkButton.click() } - /** - * Clicks Check In and waits for the request to land. The "Last Scan Result" panel is not a - * usable signal — it is already on screen from the preceding validate call. - */ + /** Clicks Check in and waits for the request to land; the scanner then reopens on its own. */ async confirmCheckIn(): Promise { const recorded = this.page.waitForResponse( (response) => response.url().includes("checkin_ticket") && response.status() === 200, ) await this.checkInButton.click() await recorded - await expect(this.ticketModal).toBeHidden() + await expect(this.manualTicketInput).toBeVisible() } async expectScannerVisible(): Promise { + await this.scanButton.click({ timeout: 15000 }) await expect(this.manualTicketInput).toBeVisible({ timeout: 15000 }) await expect(this.accessDeniedMessage).toBeHidden() } diff --git a/e2e/tests/check-in.spec.ts b/e2e/tests/check-in.spec.ts index 0d6a2679..154ce460 100644 --- a/e2e/tests/check-in.spec.ts +++ b/e2e/tests/check-in.spec.ts @@ -115,9 +115,9 @@ test.describe("Check-in scanner", () => { test.describe("Check-in access control", () => { test.use({ storageState: NO_ROLE_STATE_PATH }) - test("a user without the Frontdesk Manager role is refused", async ({ page }) => { + test("a user outside the event's team is refused", async ({ page }) => { const checkInPage = new CheckInPage(page) - await checkInPage.goto() + await checkInPage.goto(eventName) await checkInPage.expectAccessDenied() })