diff --git a/pretex/lib/pretex/check_ins.ex b/pretex/lib/pretex/check_ins.ex
index 5b55093..9f49b2d 100644
--- a/pretex/lib/pretex/check_ins.ex
+++ b/pretex/lib/pretex/check_ins.ex
@@ -5,11 +5,12 @@ defmodule Pretex.CheckIns do
alias Pretex.Repo
alias Pretex.CheckIns.CheckIn
+ alias Pretex.CheckIns.{CheckInList, CheckInListItem, Gate, GateCheckInList}
alias Pretex.Orders.{Order, OrderItem}
def checkin_topic(event_id), do: "checkins:event:#{event_id}"
- def check_in_by_ticket_code(event_id, ticket_code, operator_id) do
+ def check_in_by_ticket_code(event_id, ticket_code, operator_id, check_in_list_id \\ nil) do
order_item =
OrderItem
|> join(:inner, [oi], o in Order, on: oi.order_id == o.id)
@@ -22,37 +23,128 @@ defmodule Pretex.CheckIns do
{:error, :invalid_ticket}
%{order: %{event_id: ^event_id}} = oi ->
- validate_and_check_in(oi, event_id, operator_id)
+ validate_and_check_in(oi, event_id, operator_id, check_in_list_id)
_wrong_event ->
{:error, :wrong_event}
end
end
- defp validate_and_check_in(%{order: %{status: status}}, _event_id, _operator_id)
+ def check_in_at_gate(event_id, ticket_code, operator_id, gate_id) do
+ gate = get_gate!(gate_id)
+
+ order_item =
+ OrderItem
+ |> join(:inner, [oi], o in Order, on: oi.order_id == o.id)
+ |> where([oi, _o], oi.ticket_code == ^ticket_code)
+ |> preload([oi, o], order: o)
+ |> Repo.one()
+
+ case order_item do
+ nil ->
+ {:error, :invalid_ticket}
+
+ %{order: %{event_id: ^event_id}} = oi ->
+ matching_list =
+ Enum.find(gate.check_in_lists, fn list ->
+ item_on_list?(oi.item_id, list.id) and list_active?(list)
+ end)
+
+ case matching_list do
+ nil -> {:error, :not_on_list}
+ list -> validate_and_check_in(oi, event_id, operator_id, list.id)
+ end
+
+ _wrong_event ->
+ {:error, :wrong_event}
+ end
+ end
+
+ defp validate_and_check_in(%{order: %{status: status}}, _event_id, _operator_id, _list_id)
when status != "confirmed" do
{:error, :ticket_cancelled}
end
- defp validate_and_check_in(order_item, event_id, operator_id) do
- existing =
- CheckIn
+ defp validate_and_check_in(order_item, event_id, operator_id, check_in_list_id) do
+ with :ok <- validate_on_list(order_item, check_in_list_id),
+ :ok <- validate_list_active(check_in_list_id) do
+ existing =
+ CheckIn
+ |> where(
+ [c],
+ c.order_item_id == ^order_item.id and c.event_id == ^event_id and is_nil(c.annulled_at)
+ )
+ |> then(fn q ->
+ if check_in_list_id do
+ where(q, [c], c.check_in_list_id == ^check_in_list_id)
+ else
+ where(q, [c], is_nil(c.check_in_list_id))
+ end
+ end)
+ |> Repo.one()
+
+ case existing do
+ nil ->
+ insert_check_in(order_item.id, event_id, operator_id, check_in_list_id)
+
+ _already ->
+ {:error, :already_checked_in}
+ end
+ end
+ end
+
+ defp validate_on_list(_order_item, nil), do: :ok
+
+ defp validate_on_list(order_item, check_in_list_id) do
+ exists =
+ CheckInListItem
|> where(
- [c],
- c.order_item_id == ^order_item.id and c.event_id == ^event_id and is_nil(c.annulled_at)
+ [cli],
+ cli.check_in_list_id == ^check_in_list_id and cli.item_id == ^order_item.item_id
)
- |> Repo.one()
+ |> Repo.exists?()
- case existing do
- nil ->
- insert_check_in(order_item.id, event_id, operator_id)
+ if exists, do: :ok, else: {:error, :not_on_list}
+ end
+
+ defp validate_list_active(nil), do: :ok
+
+ defp validate_list_active(check_in_list_id) do
+ list = Repo.get!(CheckInList, check_in_list_id)
+
+ cond do
+ is_nil(list.starts_at_time) and is_nil(list.ends_at_time) ->
+ :ok
+
+ true ->
+ now = Time.utc_now()
+ starts = list.starts_at_time || ~T[00:00:00]
+ ends = list.ends_at_time || ~T[23:59:59]
- _already ->
- {:error, :already_checked_in}
+ if Time.compare(now, starts) != :lt and Time.compare(now, ends) != :gt do
+ :ok
+ else
+ {:error, :list_not_active}
+ end
end
end
- defp insert_check_in(order_item_id, event_id, operator_id) do
+ defp item_on_list?(item_id, check_in_list_id) do
+ CheckInListItem
+ |> where([cli], cli.check_in_list_id == ^check_in_list_id and cli.item_id == ^item_id)
+ |> Repo.exists?()
+ end
+
+ defp list_active?(%{starts_at_time: nil, ends_at_time: nil}), do: true
+
+ defp list_active?(list) do
+ now = Time.utc_now()
+ starts = list.starts_at_time || ~T[00:00:00]
+ ends = list.ends_at_time || ~T[23:59:59]
+ Time.compare(now, starts) != :lt and Time.compare(now, ends) != :gt
+ end
+
+ defp insert_check_in(order_item_id, event_id, operator_id, check_in_list_id) do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
result =
@@ -61,6 +153,11 @@ defmodule Pretex.CheckIns do
|> Ecto.Changeset.put_change(:order_item_id, order_item_id)
|> Ecto.Changeset.put_change(:event_id, event_id)
|> Ecto.Changeset.put_change(:checked_in_by_id, operator_id)
+ |> then(fn cs ->
+ if check_in_list_id,
+ do: Ecto.Changeset.put_change(cs, :check_in_list_id, check_in_list_id),
+ else: cs
+ end)
|> Repo.insert()
case result do
@@ -145,6 +242,164 @@ defmodule Pretex.CheckIns do
|> Repo.one()
end
+ # ---------------------------------------------------------------------------
+ # Check-in Lists CRUD
+ # ---------------------------------------------------------------------------
+
+ def create_check_in_list(event_id, attrs) do
+ item_ids = Map.get(attrs, :item_ids) || Map.get(attrs, "item_ids") || []
+
+ if item_ids == [] do
+ {:error, :no_items}
+ else
+ Repo.transaction(fn ->
+ changeset =
+ %CheckInList{}
+ |> CheckInList.changeset(attrs)
+ |> Ecto.Changeset.put_change(:event_id, event_id)
+
+ list =
+ case Repo.insert(changeset) do
+ {:ok, l} -> l
+ {:error, cs} -> Repo.rollback(cs)
+ end
+
+ Enum.each(item_ids, fn item_id ->
+ %CheckInListItem{}
+ |> Ecto.Changeset.change(%{check_in_list_id: list.id, item_id: item_id})
+ |> Repo.insert!()
+ end)
+
+ list
+ end)
+ end
+ end
+
+ def update_check_in_list(list_id, attrs) do
+ list = Repo.get!(CheckInList, list_id)
+ item_ids = Map.get(attrs, :item_ids) || Map.get(attrs, "item_ids")
+
+ Repo.transaction(fn ->
+ updated =
+ case list |> CheckInList.changeset(attrs) |> Repo.update() do
+ {:ok, l} -> l
+ {:error, cs} -> Repo.rollback(cs)
+ end
+
+ if item_ids do
+ CheckInListItem
+ |> where([cli], cli.check_in_list_id == ^list.id)
+ |> Repo.delete_all()
+
+ Enum.each(item_ids, fn item_id ->
+ %CheckInListItem{}
+ |> Ecto.Changeset.change(%{check_in_list_id: list.id, item_id: item_id})
+ |> Repo.insert!()
+ end)
+ end
+
+ updated
+ end)
+ end
+
+ def delete_check_in_list(list_id) do
+ list = Repo.get!(CheckInList, list_id)
+ Repo.delete(list)
+ end
+
+ def list_check_in_lists(event_id) do
+ CheckInList
+ |> where([l], l.event_id == ^event_id)
+ |> preload(:check_in_list_items)
+ |> order_by([l], asc: l.name)
+ |> Repo.all()
+ end
+
+ def get_check_in_list!(id) do
+ CheckInList
+ |> preload(:check_in_list_items)
+ |> Repo.get!(id)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Gates CRUD
+ # ---------------------------------------------------------------------------
+
+ def create_gate(event_id, attrs) do
+ list_ids = Map.get(attrs, :check_in_list_ids) || Map.get(attrs, "check_in_list_ids") || []
+
+ if list_ids == [] do
+ {:error, :no_check_in_lists}
+ else
+ Repo.transaction(fn ->
+ changeset =
+ %Gate{}
+ |> Gate.changeset(attrs)
+ |> Ecto.Changeset.put_change(:event_id, event_id)
+
+ gate =
+ case Repo.insert(changeset) do
+ {:ok, g} -> g
+ {:error, cs} -> Repo.rollback(cs)
+ end
+
+ Enum.each(list_ids, fn list_id ->
+ %GateCheckInList{}
+ |> Ecto.Changeset.change(%{gate_id: gate.id, check_in_list_id: list_id})
+ |> Repo.insert!()
+ end)
+
+ gate
+ end)
+ end
+ end
+
+ def update_gate(gate_id, attrs) do
+ gate = Repo.get!(Gate, gate_id)
+ list_ids = Map.get(attrs, :check_in_list_ids) || Map.get(attrs, "check_in_list_ids")
+
+ Repo.transaction(fn ->
+ updated =
+ case gate |> Gate.changeset(attrs) |> Repo.update() do
+ {:ok, g} -> g
+ {:error, cs} -> Repo.rollback(cs)
+ end
+
+ if list_ids do
+ GateCheckInList
+ |> where([gcl], gcl.gate_id == ^gate.id)
+ |> Repo.delete_all()
+
+ Enum.each(list_ids, fn list_id ->
+ %GateCheckInList{}
+ |> Ecto.Changeset.change(%{gate_id: gate.id, check_in_list_id: list_id})
+ |> Repo.insert!()
+ end)
+ end
+
+ updated
+ end)
+ end
+
+ def delete_gate(gate_id) do
+ gate = Repo.get!(Gate, gate_id)
+ Repo.delete(gate)
+ end
+
+ def list_gates(event_id) do
+ Gate
+ |> where([g], g.event_id == ^event_id)
+ |> preload(:check_in_lists)
+ |> order_by([g], asc: g.name)
+ |> Repo.all()
+ end
+
+ def get_gate!(id) do
+ Gate
+ |> preload(check_in_lists: :check_in_list_items)
+ |> Repo.get!(id)
+ end
+
defp broadcast_check_in_update(event_id) do
count = get_check_in_count(event_id)
Phoenix.PubSub.broadcast(Pretex.PubSub, checkin_topic(event_id), {:check_in_updated, count})
diff --git a/pretex/lib/pretex/check_ins/check_in.ex b/pretex/lib/pretex/check_ins/check_in.ex
index e3a3543..83bcb88 100644
--- a/pretex/lib/pretex/check_ins/check_in.ex
+++ b/pretex/lib/pretex/check_ins/check_in.ex
@@ -10,6 +10,7 @@ defmodule Pretex.CheckIns.CheckIn do
belongs_to(:event, Pretex.Events.Event)
belongs_to(:checked_in_by, Pretex.Accounts.User, foreign_key: :checked_in_by_id)
belongs_to(:annulled_by, Pretex.Accounts.User, foreign_key: :annulled_by_id)
+ belongs_to(:check_in_list, Pretex.CheckIns.CheckInList)
timestamps(type: :utc_datetime)
end
diff --git a/pretex/lib/pretex/check_ins/check_in_list.ex b/pretex/lib/pretex/check_ins/check_in_list.ex
new file mode 100644
index 0000000..dc8f36b
--- /dev/null
+++ b/pretex/lib/pretex/check_ins/check_in_list.ex
@@ -0,0 +1,35 @@
+defmodule Pretex.CheckIns.CheckInList do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "check_in_lists" do
+ field(:name, :string)
+ field(:starts_at_time, :time)
+ field(:ends_at_time, :time)
+
+ belongs_to(:event, Pretex.Events.Event)
+ has_many(:check_in_list_items, Pretex.CheckIns.CheckInListItem)
+ many_to_many(:gates, Pretex.CheckIns.Gate, join_through: "gate_check_in_lists")
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(list, attrs) do
+ list
+ |> cast(attrs, [:name, :starts_at_time, :ends_at_time])
+ |> validate_required([:name])
+ |> validate_length(:name, min: 1, max: 255)
+ |> validate_time_window()
+ end
+
+ defp validate_time_window(changeset) do
+ starts = get_field(changeset, :starts_at_time)
+ ends = get_field(changeset, :ends_at_time)
+
+ if starts && ends && Time.compare(ends, starts) != :gt do
+ add_error(changeset, :ends_at_time, "must be after start time")
+ else
+ changeset
+ end
+ end
+end
diff --git a/pretex/lib/pretex/check_ins/check_in_list_item.ex b/pretex/lib/pretex/check_ins/check_in_list_item.ex
new file mode 100644
index 0000000..20013ae
--- /dev/null
+++ b/pretex/lib/pretex/check_ins/check_in_list_item.ex
@@ -0,0 +1,9 @@
+defmodule Pretex.CheckIns.CheckInListItem do
+ use Ecto.Schema
+
+ schema "check_in_list_items" do
+ belongs_to(:check_in_list, Pretex.CheckIns.CheckInList)
+ belongs_to(:item, Pretex.Catalog.Item)
+ belongs_to(:item_variation, Pretex.Catalog.ItemVariation)
+ end
+end
diff --git a/pretex/lib/pretex/check_ins/gate.ex b/pretex/lib/pretex/check_ins/gate.ex
new file mode 100644
index 0000000..ecdca83
--- /dev/null
+++ b/pretex/lib/pretex/check_ins/gate.ex
@@ -0,0 +1,23 @@
+defmodule Pretex.CheckIns.Gate do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "gates" do
+ field(:name, :string)
+
+ belongs_to(:event, Pretex.Events.Event)
+
+ many_to_many(:check_in_lists, Pretex.CheckIns.CheckInList,
+ join_through: "gate_check_in_lists"
+ )
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(gate, attrs) do
+ gate
+ |> cast(attrs, [:name])
+ |> validate_required([:name])
+ |> validate_length(:name, min: 1, max: 255)
+ end
+end
diff --git a/pretex/lib/pretex/check_ins/gate_check_in_list.ex b/pretex/lib/pretex/check_ins/gate_check_in_list.ex
new file mode 100644
index 0000000..29ac973
--- /dev/null
+++ b/pretex/lib/pretex/check_ins/gate_check_in_list.ex
@@ -0,0 +1,9 @@
+defmodule Pretex.CheckIns.GateCheckInList do
+ use Ecto.Schema
+
+ @primary_key false
+ schema "gate_check_in_lists" do
+ belongs_to(:gate, Pretex.CheckIns.Gate)
+ belongs_to(:check_in_list, Pretex.CheckIns.CheckInList)
+ end
+end
diff --git a/pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex
new file mode 100644
index 0000000..dd60000
--- /dev/null
+++ b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex
@@ -0,0 +1,235 @@
+defmodule PretexWeb.Admin.CheckInConfigLive.Index do
+ use PretexWeb, :live_view
+
+ alias Pretex.Catalog
+ alias Pretex.CheckIns
+
+ alias Pretex.Events
+ alias Pretex.Organizations
+
+ @impl true
+ def mount(%{"org_id" => org_id, "event_id" => event_id}, _session, socket) do
+ org = Organizations.get_organization!(org_id)
+ event = Events.get_event!(event_id)
+ check_in_lists = CheckIns.list_check_in_lists(event.id)
+ gates = CheckIns.list_gates(event.id)
+ catalog_items = Catalog.list_items(event)
+
+ socket =
+ socket
+ |> assign(:org, org)
+ |> assign(:event, event)
+ |> assign(:catalog_items, catalog_items)
+ |> assign(:check_in_lists, check_in_lists)
+ |> assign(:gates, gates)
+ |> assign(:form, nil)
+ |> assign(:selected_item_ids, [])
+ |> assign(:selected_list_ids, [])
+ |> assign(:page_title, "Configuração de Check-in — #{event.name}")
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :index, _params) do
+ socket
+ |> assign(:form, nil)
+ |> assign(:selected_item_ids, [])
+ |> assign(:selected_list_ids, [])
+ end
+
+ defp apply_action(socket, :new_list, _params) do
+ form = to_form(%{"name" => "", "starts_at_time" => "", "ends_at_time" => ""}, as: :list)
+
+ socket
+ |> assign(:form, form)
+ |> assign(:selected_item_ids, [])
+ end
+
+ defp apply_action(socket, :edit_list, %{"list_id" => list_id}) do
+ list = CheckIns.get_check_in_list!(list_id)
+ item_ids = Enum.map(list.check_in_list_items, & &1.item_id)
+
+ form =
+ to_form(
+ %{
+ "name" => list.name,
+ "starts_at_time" =>
+ if(list.starts_at_time, do: Time.to_string(list.starts_at_time), else: ""),
+ "ends_at_time" => if(list.ends_at_time, do: Time.to_string(list.ends_at_time), else: "")
+ },
+ as: :list
+ )
+
+ socket
+ |> assign(:form, form)
+ |> assign(:editing_list_id, list_id)
+ |> assign(:selected_item_ids, item_ids)
+ end
+
+ defp apply_action(socket, :new_gate, _params) do
+ form = to_form(%{"name" => ""}, as: :gate)
+
+ socket
+ |> assign(:form, form)
+ |> assign(:selected_list_ids, [])
+ end
+
+ defp apply_action(socket, :edit_gate, %{"gate_id" => gate_id}) do
+ gate = CheckIns.get_gate!(gate_id)
+ list_ids = Enum.map(gate.check_in_lists, & &1.id)
+
+ form = to_form(%{"name" => gate.name}, as: :gate)
+
+ socket
+ |> assign(:form, form)
+ |> assign(:editing_gate_id, gate_id)
+ |> assign(:selected_list_ids, list_ids)
+ end
+
+ @impl true
+ def handle_event("toggle_item", %{"id" => id_str}, socket) do
+ id = String.to_integer(id_str)
+ current = socket.assigns.selected_item_ids
+
+ updated =
+ if id in current,
+ do: List.delete(current, id),
+ else: [id | current]
+
+ {:noreply, assign(socket, :selected_item_ids, updated)}
+ end
+
+ @impl true
+ def handle_event("toggle_list", %{"id" => id_str}, socket) do
+ id = String.to_integer(id_str)
+ current = socket.assigns.selected_list_ids
+
+ updated =
+ if id in current,
+ do: List.delete(current, id),
+ else: [id | current]
+
+ {:noreply, assign(socket, :selected_list_ids, updated)}
+ end
+
+ @impl true
+ def handle_event("save_list", %{"list" => params}, socket) do
+ event = socket.assigns.event
+ item_ids = socket.assigns.selected_item_ids
+
+ attrs = %{
+ name: params["name"],
+ item_ids: item_ids,
+ starts_at_time: parse_time(params["starts_at_time"]),
+ ends_at_time: parse_time(params["ends_at_time"])
+ }
+
+ result =
+ if socket.assigns.live_action == :edit_list do
+ CheckIns.update_check_in_list(socket.assigns.editing_list_id, attrs)
+ else
+ CheckIns.create_check_in_list(event.id, attrs)
+ end
+
+ case result do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> assign(:check_in_lists, CheckIns.list_check_in_lists(event.id))
+ |> put_flash(:info, "Lista salva com sucesso.")
+ |> push_patch(to: config_path(socket))}
+
+ {:error, :no_items} ->
+ {:noreply, put_flash(socket, :error, "Selecione pelo menos um item.")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Erro ao salvar lista.")}
+ end
+ end
+
+ @impl true
+ def handle_event("save_gate", %{"gate" => params}, socket) do
+ event = socket.assigns.event
+ list_ids = socket.assigns.selected_list_ids
+
+ attrs = %{
+ name: params["name"],
+ check_in_list_ids: list_ids
+ }
+
+ result =
+ if socket.assigns.live_action == :edit_gate do
+ CheckIns.update_gate(socket.assigns.editing_gate_id, attrs)
+ else
+ CheckIns.create_gate(event.id, attrs)
+ end
+
+ case result do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> assign(:gates, CheckIns.list_gates(event.id))
+ |> put_flash(:info, "Portão salvo com sucesso.")
+ |> push_patch(to: config_path(socket))}
+
+ {:error, :no_check_in_lists} ->
+ {:noreply, put_flash(socket, :error, "Selecione pelo menos uma lista.")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Erro ao salvar portão.")}
+ end
+ end
+
+ @impl true
+ def handle_event("delete_list", %{"id" => id_str}, socket) do
+ id = String.to_integer(id_str)
+ {:ok, _} = CheckIns.delete_check_in_list(id)
+
+ {:noreply,
+ socket
+ |> assign(:check_in_lists, CheckIns.list_check_in_lists(socket.assigns.event.id))
+ |> assign(:gates, CheckIns.list_gates(socket.assigns.event.id))
+ |> put_flash(:info, "Lista excluída.")}
+ end
+
+ @impl true
+ def handle_event("delete_gate", %{"id" => id_str}, socket) do
+ id = String.to_integer(id_str)
+ {:ok, _} = CheckIns.delete_gate(id)
+
+ {:noreply,
+ socket
+ |> assign(:gates, CheckIns.list_gates(socket.assigns.event.id))
+ |> put_flash(:info, "Portão excluído.")}
+ end
+
+ @impl true
+ def handle_event("close_modal", _, socket) do
+ {:noreply, push_patch(socket, to: config_path(socket))}
+ end
+
+ defp config_path(socket) do
+ ~p"/admin/organizations/#{socket.assigns.org}/events/#{socket.assigns.event}/check-in/config"
+ end
+
+ defp parse_time(""), do: nil
+ defp parse_time(nil), do: nil
+
+ defp parse_time(str) do
+ case Time.from_iso8601(str <> ":00") do
+ {:ok, time} ->
+ time
+
+ _ ->
+ case Time.from_iso8601(str) do
+ {:ok, time} -> time
+ _ -> nil
+ end
+ end
+ end
+end
diff --git a/pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex
new file mode 100644
index 0000000..707aa6d
--- /dev/null
+++ b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex
@@ -0,0 +1,293 @@
+<.dashboard_layout
+ current_path={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config"}
+ org={@org}
+ flash={@flash}
+>
+
+ <%!-- Back link --%>
+
+ <.link
+ navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"}
+ class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-primary transition-colors"
+ >
+ <.icon name="hero-arrow-left" class="size-4" /> Voltar ao Check-in
+
+
+
+
+
Configuração de Check-in
+
{@event.name}
+
+
+
+ <%!-- Check-in Lists Section --%>
+
+
+
+ <.icon name="hero-clipboard-document-list" class="size-5 text-base-content/40" />
+ Listas de Check-in
+
+ <.link
+ patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/lists/new"}
+ class="btn btn-primary btn-sm gap-1"
+ >
+ <.icon name="hero-plus" class="size-4" /> Nova Lista
+
+
+
+
+
+ <.icon
+ name="hero-clipboard-document-list"
+ class="size-12 text-base-content/20 mx-auto mb-3"
+ />
+
Nenhuma lista de check-in configurada.
+
+ Crie listas para controlar quais ingressos são válidos em cada ponto de entrada.
+
+
+
+
+
+
{list.name}
+
+ {length(list.check_in_list_items)} item(s)
+ <%= if list.starts_at_time do %>
+ · {Calendar.strftime(list.starts_at_time, "%H:%M")} — {Calendar.strftime(list.ends_at_time, "%H:%M")}
+ <% end %>
+
+
+
+ <.link
+ patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/lists/#{list.id}/edit"}
+ class="btn btn-ghost btn-xs"
+ >
+ Editar
+
+
+
+
+
+
+
+ <%!-- Gates Section --%>
+
+
+
+ <.icon name="hero-building-storefront" class="size-5 text-base-content/40" />
+ Portões
+
+ <.link
+ patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/gates/new"}
+ class="btn btn-primary btn-sm gap-1"
+ >
+ <.icon name="hero-plus" class="size-4" /> Novo Portão
+
+
+
+
+
+ <.icon
+ name="hero-building-storefront"
+ class="size-12 text-base-content/20 mx-auto mb-3"
+ />
+
Nenhum portão configurado.
+
+ Portões representam pontos de entrada físicos vinculados a listas de check-in.
+
+
+
+
+
+
{gate.name}
+
+ {Enum.map_join(gate.check_in_lists, ", ", & &1.name)}
+
+
+
+ <.link
+ patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/gates/#{gate.id}/edit"}
+ class="btn btn-ghost btn-xs"
+ >
+ Editar
+
+
+
+
+
+
+
+
+
+ <%!-- Modal: New/Edit List --%>
+
+
+
+
+
+
+
+ {if @live_action == :new_list, do: "Nova Lista de Check-in", else: "Editar Lista"}
+
+
+
+
+
+ <.form for={@form} id="list-form" phx-submit="save_list" class="space-y-4">
+ <.input
+ field={@form[:name]}
+ type="text"
+ label="Nome"
+ placeholder="Ex: Entrada VIP"
+ required
+ />
+
+
+ <.input
+ field={@form[:starts_at_time]}
+ type="time"
+ label="Horário de início (opcional)"
+ />
+ <.input
+ field={@form[:ends_at_time]}
+ type="time"
+ label="Horário de fim (opcional)"
+ />
+
+
+
+
+
+
+
+
+ Nenhum item no catálogo deste evento.
+
+
+
+
+
+ <.button type="submit" variant="primary" phx-disable-with="Salvando...">
+ {if @live_action == :new_list, do: "Criar Lista", else: "Salvar"}
+
+
+
+
+
+
+
+
+ <%!-- Modal: New/Edit Gate --%>
+
+
+
+
+
+
+
+ {if @live_action == :new_gate, do: "Novo Portão", else: "Editar Portão"}
+
+
+
+
+
+ <.form for={@form} id="gate-form" phx-submit="save_gate" class="space-y-4">
+ <.input
+ field={@form[:name]}
+ type="text"
+ label="Nome"
+ placeholder="Ex: Porta Norte"
+ required
+ />
+
+
+
+
+
+
+
+ Crie uma lista de check-in primeiro.
+
+
+
+
+
+ <.button type="submit" variant="primary" phx-disable-with="Salvando...">
+ {if @live_action == :new_gate, do: "Criar Portão", else: "Salvar"}
+
+
+
+
+
+
+
+
diff --git a/pretex/lib/pretex_web/live/admin/check_in_live/index.ex b/pretex/lib/pretex_web/live/admin/check_in_live/index.ex
index 0ec2a96..9a1c2c2 100644
--- a/pretex/lib/pretex_web/live/admin/check_in_live/index.ex
+++ b/pretex/lib/pretex_web/live/admin/check_in_live/index.ex
@@ -16,6 +16,7 @@ defmodule PretexWeb.Admin.CheckInLive.Index do
checked_in_count = CheckIns.get_check_in_count(event.id)
total_tickets = CheckIns.get_total_tickets(event.id)
+ gates = CheckIns.list_gates(event.id)
socket =
socket
@@ -26,17 +27,36 @@ defmodule PretexWeb.Admin.CheckInLive.Index do
|> assign(:scan_result, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
+ |> assign(:gates, gates)
+ |> assign(:selected_gate_id, nil)
|> assign(:page_title, "Check-in — #{event.name}")
{:ok, socket}
end
+ @impl true
+ def handle_event("select_gate", %{"gate_id" => gate_id}, socket) do
+ selected = if gate_id == "", do: nil, else: String.to_integer(gate_id)
+
+ {:noreply,
+ socket
+ |> assign(:selected_gate_id, selected)
+ |> assign(:search_query, "")
+ |> assign(:search_results, [])}
+ end
+
@impl true
def handle_event("scan", %{"code" => code}, socket) do
event = socket.assigns.event
operator_id = socket.assigns.current_user.id
+ gate_id = socket.assigns.selected_gate_id
- result = CheckIns.check_in_by_ticket_code(event.id, code, operator_id)
+ result =
+ if gate_id do
+ CheckIns.check_in_at_gate(event.id, code, operator_id, gate_id)
+ else
+ CheckIns.check_in_by_ticket_code(event.id, code, operator_id)
+ end
scan_result =
case result do
@@ -63,6 +83,20 @@ defmodule PretexWeb.Admin.CheckInLive.Index do
{:error, :already_checked_in} ->
%{status: :error, message: "Já foi feito check-in", attendee_name: nil}
+
+ {:error, :not_on_list} ->
+ %{
+ status: :error,
+ message: "Ingresso não válido para este ponto de entrada",
+ attendee_name: nil
+ }
+
+ {:error, :list_not_active} ->
+ %{
+ status: :error,
+ message: "Lista de check-in fora do horário ativo",
+ attendee_name: nil
+ }
end
socket =
@@ -108,8 +142,16 @@ defmodule PretexWeb.Admin.CheckInLive.Index do
def handle_event("check_in_attendee", %{"ticket-code" => code}, socket) do
event = socket.assigns.event
operator_id = socket.assigns.current_user.id
+ gate_id = socket.assigns.selected_gate_id
+
+ result =
+ if gate_id do
+ CheckIns.check_in_at_gate(event.id, code, operator_id, gate_id)
+ else
+ CheckIns.check_in_by_ticket_code(event.id, code, operator_id)
+ end
- case CheckIns.check_in_by_ticket_code(event.id, code, operator_id) do
+ case result do
{:ok, _} ->
results = refresh_search(socket)
@@ -124,6 +166,8 @@ defmodule PretexWeb.Admin.CheckInLive.Index do
case reason do
:already_checked_in -> "Já foi feito check-in"
:ticket_cancelled -> "Ingresso cancelado"
+ :not_on_list -> "Ingresso não válido para este ponto de entrada"
+ :list_not_active -> "Lista fora do horário ativo"
_ -> "Erro ao fazer check-in"
end
diff --git a/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex b/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex
index 84e560b..349a650 100644
--- a/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex
+++ b/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex
@@ -5,13 +5,19 @@
>
<%!-- Back link --%>
-
+
<.link
navigate={~p"/admin/organizations/#{@org}/events/#{@event}"}
class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-primary transition-colors"
>
<.icon name="hero-arrow-left" class="size-4" /> Voltar ao Evento
+ <.link
+ navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config"}
+ class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-primary transition-colors"
+ >
+ <.icon name="hero-cog-6-tooth" class="size-4" /> Configurar
+
@@ -27,6 +33,21 @@
+ <%!-- Gate selector --%>
+
+
+
+
+
<%!-- Scan result feedback --%>
<.icon name="hero-qr-code" class="size-4 text-base-content/40" /> Check-in
- <.link
- navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"}
- class="btn btn-ghost btn-xs gap-1"
- >
- <.icon name="hero-arrow-right" class="size-3" /> Abrir Check-in
-
+
+ <.link
+ navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config"}
+ class="btn btn-ghost btn-xs gap-1"
+ >
+ <.icon name="hero-cog-6-tooth" class="size-3" /> Configurar
+
+ <.link
+ navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"}
+ class="btn btn-ghost btn-xs gap-1"
+ >
+ <.icon name="hero-arrow-right" class="size-3" /> Abrir Check-in
+
+
<.icon
diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex
index 4b23a43..3965721 100644
--- a/pretex/lib/pretex_web/router.ex
+++ b/pretex/lib/pretex_web/router.ex
@@ -168,6 +168,36 @@ defmodule PretexWeb.Router do
live("/organizations/:org_id/events/:event_id/check-in", CheckInLive.Index, :index)
+ live(
+ "/organizations/:org_id/events/:event_id/check-in/config",
+ CheckInConfigLive.Index,
+ :index
+ )
+
+ live(
+ "/organizations/:org_id/events/:event_id/check-in/config/lists/new",
+ CheckInConfigLive.Index,
+ :new_list
+ )
+
+ live(
+ "/organizations/:org_id/events/:event_id/check-in/config/lists/:list_id/edit",
+ CheckInConfigLive.Index,
+ :edit_list
+ )
+
+ live(
+ "/organizations/:org_id/events/:event_id/check-in/config/gates/new",
+ CheckInConfigLive.Index,
+ :new_gate
+ )
+
+ live(
+ "/organizations/:org_id/events/:event_id/check-in/config/gates/:gate_id/edit",
+ CheckInConfigLive.Index,
+ :edit_gate
+ )
+
live("/organizations/:org_id/events/:event_id/fees", FeeLive.Index, :index)
live("/organizations/:org_id/events/:event_id/fees/new", FeeLive.Index, :new)
live("/organizations/:org_id/events/:event_id/fees/:id/edit", FeeLive.Index, :edit)
diff --git a/pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs b/pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs
new file mode 100644
index 0000000..837cb3c
--- /dev/null
+++ b/pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs
@@ -0,0 +1,58 @@
+defmodule Pretex.Repo.Migrations.CreateCheckInListsAndGates do
+ use Ecto.Migration
+
+ def change do
+ create table(:check_in_lists) do
+ add :name, :string, null: false
+ add :starts_at_time, :time
+ add :ends_at_time, :time
+ add :event_id, references(:events, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:check_in_lists, [:event_id])
+
+ create table(:check_in_list_items) do
+ add :check_in_list_id, references(:check_in_lists, on_delete: :delete_all), null: false
+ add :item_id, references(:items, on_delete: :delete_all), null: false
+ add :item_variation_id, references(:item_variations, on_delete: :delete_all)
+ end
+
+ create unique_index(:check_in_list_items, [:check_in_list_id, :item_id, :item_variation_id],
+ name: :check_in_list_items_unique
+ )
+
+ create table(:gates) do
+ add :name, :string, null: false
+ add :event_id, references(:events, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:gates, [:event_id])
+
+ create table(:gate_check_in_lists, primary_key: false) do
+ add :gate_id, references(:gates, on_delete: :delete_all), null: false
+ add :check_in_list_id, references(:check_in_lists, on_delete: :delete_all), null: false
+ end
+
+ create unique_index(:gate_check_in_lists, [:gate_id, :check_in_list_id])
+
+ alter table(:check_ins) do
+ add :check_in_list_id, references(:check_in_lists, on_delete: :restrict)
+ end
+
+ drop unique_index(:check_ins, [:order_item_id, :event_id],
+ where: "annulled_at IS NULL",
+ name: :check_ins_active_unique
+ )
+
+ create unique_index(:check_ins, [:order_item_id, :event_id, "COALESCE(check_in_list_id, 0)"],
+ where: "annulled_at IS NULL",
+ name: :check_ins_active_unique
+ )
+
+ create index(:check_ins, [:check_in_list_id])
+ end
+end
diff --git a/pretex/test/pretex/check_in_lists_test.exs b/pretex/test/pretex/check_in_lists_test.exs
new file mode 100644
index 0000000..fd06ce6
--- /dev/null
+++ b/pretex/test/pretex/check_in_lists_test.exs
@@ -0,0 +1,150 @@
+defmodule Pretex.CheckInListsTest do
+ use Pretex.DataCase, async: true
+
+ import Pretex.OrganizationsFixtures
+ import Pretex.EventsFixtures
+ import Pretex.CatalogFixtures
+
+ alias Pretex.CheckIns
+ alias Pretex.CheckIns.{CheckInList, Gate}
+
+ describe "check-in lists CRUD" do
+ test "create_check_in_list/2 creates a list with items" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ assert {:ok, %CheckInList{} = list} =
+ CheckIns.create_check_in_list(event.id, %{
+ name: "VIP Entrance",
+ item_ids: [item.id]
+ })
+
+ assert list.name == "VIP Entrance"
+ list = Pretex.Repo.preload(list, :check_in_list_items)
+ assert length(list.check_in_list_items) == 1
+ end
+
+ test "create_check_in_list/2 fails without items" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+
+ assert {:error, :no_items} =
+ CheckIns.create_check_in_list(event.id, %{name: "Empty", item_ids: []})
+ end
+
+ test "create_check_in_list/2 with time restrictions" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ assert {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{
+ name: "Morning Only",
+ item_ids: [item.id],
+ starts_at_time: ~T[08:00:00],
+ ends_at_time: ~T[10:00:00]
+ })
+
+ assert list.starts_at_time == ~T[08:00:00]
+ assert list.ends_at_time == ~T[10:00:00]
+ end
+
+ test "list_check_in_lists/1 returns all lists for event" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ {:ok, _} = CheckIns.create_check_in_list(event.id, %{name: "List A", item_ids: [item.id]})
+ {:ok, _} = CheckIns.create_check_in_list(event.id, %{name: "List B", item_ids: [item.id]})
+
+ lists = CheckIns.list_check_in_lists(event.id)
+ assert length(lists) == 2
+ end
+
+ test "update_check_in_list/2 updates name and items" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item1 = item_fixture(event)
+ item2 = item_fixture(event)
+
+ {:ok, list} = CheckIns.create_check_in_list(event.id, %{name: "Old", item_ids: [item1.id]})
+
+ assert {:ok, updated} =
+ CheckIns.update_check_in_list(list.id, %{name: "New", item_ids: [item2.id]})
+
+ assert updated.name == "New"
+ updated = Pretex.Repo.preload(updated, :check_in_list_items)
+ assert length(updated.check_in_list_items) == 1
+ assert hd(updated.check_in_list_items).item_id == item2.id
+ end
+
+ test "delete_check_in_list/1 removes list" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "Delete Me", item_ids: [item.id]})
+
+ assert {:ok, _} = CheckIns.delete_check_in_list(list.id)
+ assert CheckIns.list_check_in_lists(event.id) == []
+ end
+ end
+
+ describe "gates CRUD" do
+ test "create_gate/2 creates a gate with check-in lists" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [item.id]})
+
+ assert {:ok, %Gate{} = gate} =
+ CheckIns.create_gate(event.id, %{name: "North Door", check_in_list_ids: [list.id]})
+
+ assert gate.name == "North Door"
+ gate = Pretex.Repo.preload(gate, :check_in_lists)
+ assert length(gate.check_in_lists) == 1
+ end
+
+ test "create_gate/2 fails without check-in lists" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+
+ assert {:error, :no_check_in_lists} =
+ CheckIns.create_gate(event.id, %{name: "Empty Gate", check_in_list_ids: []})
+ end
+
+ test "list_gates/1 returns all gates for event" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [item.id]})
+
+ {:ok, _} = CheckIns.create_gate(event.id, %{name: "Gate A", check_in_list_ids: [list.id]})
+ {:ok, _} = CheckIns.create_gate(event.id, %{name: "Gate B", check_in_list_ids: [list.id]})
+
+ gates = CheckIns.list_gates(event.id)
+ assert length(gates) == 2
+ end
+
+ test "delete_gate/1 removes gate" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ item = item_fixture(event)
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [item.id]})
+
+ {:ok, gate} =
+ CheckIns.create_gate(event.id, %{name: "Delete Me", check_in_list_ids: [list.id]})
+
+ assert {:ok, _} = CheckIns.delete_gate(gate.id)
+ assert CheckIns.list_gates(event.id) == []
+ end
+ end
+end
diff --git a/pretex/test/pretex/check_ins_test.exs b/pretex/test/pretex/check_ins_test.exs
index 764c069..ea0da36 100644
--- a/pretex/test/pretex/check_ins_test.exs
+++ b/pretex/test/pretex/check_ins_test.exs
@@ -185,4 +185,143 @@ defmodule Pretex.CheckInsTest do
assert CheckIns.get_check_in_count(event.id) == 0
end
end
+
+ describe "check_in_by_ticket_code/4 with check_in_list" do
+ test "checks in on a specific list" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{
+ name: "Main Hall",
+ item_ids: [order_item.item_id]
+ })
+
+ assert {:ok, check_in} =
+ CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id,
+ list.id
+ )
+
+ assert check_in.check_in_list_id == list.id
+ end
+
+ test "same ticket can be checked in on different lists independently" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+
+ {:ok, list1} =
+ CheckIns.create_check_in_list(event.id, %{name: "Hall A", item_ids: [order_item.item_id]})
+
+ {:ok, list2} =
+ CheckIns.create_check_in_list(event.id, %{name: "Hall B", item_ids: [order_item.item_id]})
+
+ assert {:ok, _} =
+ CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id,
+ list1.id
+ )
+
+ assert {:ok, _} =
+ CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id,
+ list2.id
+ )
+ end
+
+ test "returns :not_on_list when ticket item is not in check-in list" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+ other_item = item_fixture(event, %{name: "VIP Only"})
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "VIP", item_ids: [other_item.id]})
+
+ assert {:error, :not_on_list} =
+ CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id,
+ list.id
+ )
+ end
+
+ test "returns :list_not_active when outside time window" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{
+ name: "Morning",
+ item_ids: [order_item.item_id],
+ starts_at_time: ~T[01:00:00],
+ ends_at_time: ~T[01:01:00]
+ })
+
+ assert {:error, :list_not_active} =
+ CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id,
+ list.id
+ )
+ end
+ end
+
+ describe "check_in_at_gate/4" do
+ test "checks in via gate on matching list" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [order_item.item_id]})
+
+ {:ok, gate} =
+ CheckIns.create_gate(event.id, %{name: "North Door", check_in_list_ids: [list.id]})
+
+ assert {:ok, check_in} =
+ CheckIns.check_in_at_gate(event.id, order_item.ticket_code, operator.id, gate.id)
+
+ assert check_in.check_in_list_id == list.id
+ end
+
+ test "returns :not_on_list when ticket doesn't match any gate list" do
+ org = org_fixture()
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+ other_item = item_fixture(event, %{name: "VIP Only"})
+
+ {:ok, list} =
+ CheckIns.create_check_in_list(event.id, %{name: "VIP", item_ids: [other_item.id]})
+
+ {:ok, gate} =
+ CheckIns.create_gate(event.id, %{name: "VIP Door", check_in_list_ids: [list.id]})
+
+ assert {:error, :not_on_list} =
+ CheckIns.check_in_at_gate(event.id, order_item.ticket_code, operator.id, gate.id)
+ end
+ end
end