diff --git a/pretex/lib/pretex/events/event.ex b/pretex/lib/pretex/events/event.ex
index 9ec6bd7..339a364 100644
--- a/pretex/lib/pretex/events/event.ex
+++ b/pretex/lib/pretex/events/event.ex
@@ -22,6 +22,7 @@ defmodule Pretex.Events.Event do
field(:multi_entry, :boolean, default: false)
belongs_to(:organization, Pretex.Organizations.Organization)
+ belongs_to(:seating_plan, Pretex.Seating.SeatingPlan)
has_many(:sub_events, Pretex.Events.SubEvent, foreign_key: :parent_event_id)
timestamps(type: :utc_datetime)
diff --git a/pretex/lib/pretex/seating.ex b/pretex/lib/pretex/seating.ex
new file mode 100644
index 0000000..042aaa7
--- /dev/null
+++ b/pretex/lib/pretex/seating.ex
@@ -0,0 +1,482 @@
+defmodule Pretex.Seating do
+ @moduledoc """
+ Context for managing venue seating plans, sections, seats, and reservations.
+
+ ## Seating Plan Lifecycle
+
+ 1. Organizer uploads a JSON layout via `create_seating_plan/2`.
+ 2. `parse_layout/1` validates the JSON structure and returns a list of section
+ maps, each containing a list of seat maps ready for bulk insertion.
+ 3. The plan is assigned to an event via `assign_plan_to_event/2`.
+ 4. Sections are mapped to catalog items via `map_section_to_item/3`.
+
+ ## Seat Reservation Lifecycle
+
+ During checkout: `hold_seat/3` creates a temporary held reservation linked to
+ the cart session. On cart expiry: `release_seat/2` frees the seat. On order
+ confirmation: `confirm_seat/3` upgrades the reservation to confirmed and links
+ it to the order item.
+
+ ## Concurrency
+
+ Concurrent hold attempts on the same seat are safely rejected by the partial
+ unique database index on `[seat_id, event_id]` where `status != 'released'`.
+ The caller receives `{:error, :already_reserved}` in that case.
+ """
+
+ import Ecto.Query
+
+ alias Pretex.Repo
+ alias Pretex.Seating.Seat
+ alias Pretex.Seating.SeatingPlan
+ alias Pretex.Seating.SeatingSection
+ alias Pretex.Seating.SeatReservation
+ alias Pretex.Events.Event
+
+ # ---------------------------------------------------------------------------
+ # Seating Plans
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Returns all seating plans belonging to an organization, ordered by name.
+ """
+ @spec list_seating_plans(integer()) :: [SeatingPlan.t()]
+ def list_seating_plans(org_id) do
+ SeatingPlan
+ |> where([p], p.organization_id == ^org_id)
+ |> order_by([p], asc: p.name)
+ |> Repo.all()
+ end
+
+ @doc """
+ Fetches a seating plan by id, raising if not found.
+ Preloads sections and their seats.
+ """
+ @spec get_seating_plan!(integer()) :: SeatingPlan.t()
+ def get_seating_plan!(id) do
+ SeatingPlan
+ |> preload(sections: [:seats, :item, :item_variation])
+ |> Repo.get!(id)
+ end
+
+ @doc """
+ Creates a seating plan for an organization by parsing the given JSON layout.
+
+ The `layout` key in `attrs` must be a map conforming to the expected JSON
+ structure (see `parse_layout/1`). Sections and seats are inserted within a
+ single database transaction.
+
+ Returns `{:ok, seating_plan}` on success, or `{:error, reason}` where reason
+ is either an `Ecto.Changeset` or `:invalid_layout`.
+ """
+ @spec create_seating_plan(integer(), map()) ::
+ {:ok, SeatingPlan.t()} | {:error, Ecto.Changeset.t() | :invalid_layout}
+ def create_seating_plan(org_id, attrs) do
+ layout = Map.get(attrs, :layout, Map.get(attrs, "layout"))
+
+ with {:ok, sections_data} <- parse_layout(layout) do
+ Repo.transaction(fn ->
+ plan_attrs = Map.put(attrs, :organization_id, org_id)
+
+ plan =
+ case %SeatingPlan{}
+ |> SeatingPlan.changeset(plan_attrs)
+ |> Repo.insert() do
+ {:ok, p} -> p
+ {:error, cs} -> Repo.rollback(cs)
+ end
+
+ Enum.each(sections_data, fn section_data ->
+ {seats_data, section_attrs} = Map.pop!(section_data, :seats)
+
+ section =
+ %SeatingSection{}
+ |> SeatingSection.changeset(Map.put(section_attrs, :seating_plan_id, plan.id))
+ |> Repo.insert!()
+
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ seat_rows =
+ Enum.map(seats_data, fn seat ->
+ seat
+ |> Map.put(:seating_section_id, section.id)
+ |> Map.put(:inserted_at, now)
+ |> Map.put(:updated_at, now)
+ end)
+
+ Repo.insert_all(Seat, seat_rows)
+ end)
+
+ get_seating_plan!(plan.id)
+ end)
+ end
+ end
+
+ @doc """
+ Updates an existing seating plan's name.
+ """
+ @spec update_seating_plan(SeatingPlan.t(), map()) ::
+ {:ok, SeatingPlan.t()} | {:error, Ecto.Changeset.t()}
+ def update_seating_plan(%SeatingPlan{} = plan, attrs) do
+ plan
+ |> SeatingPlan.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a seating plan. Cascades to sections and seats via DB constraints.
+ """
+ @spec delete_seating_plan(SeatingPlan.t()) ::
+ {:ok, SeatingPlan.t()} | {:error, Ecto.Changeset.t()}
+ def delete_seating_plan(%SeatingPlan{} = plan) do
+ Repo.delete(plan)
+ end
+
+ @doc """
+ Returns a changeset for a seating plan without persisting it.
+ """
+ @spec change_seating_plan(SeatingPlan.t(), map()) :: Ecto.Changeset.t()
+ def change_seating_plan(%SeatingPlan{} = plan, attrs \\ %{}) do
+ SeatingPlan.changeset(plan, attrs)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Layout Parsing
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Parses a JSON layout map into a list of section data maps, each containing
+ pre-built seat maps ready for insertion.
+
+ Expected input structure:
+
+ %{
+ "sections" => [
+ %{
+ "name" => "Orchestra",
+ "rows" => [
+ %{"label" => "A", "seats" => 20},
+ %{"label" => "B", "seats" => 20}
+ ]
+ }
+ ]
+ }
+
+ Returns `{:ok, sections}` where each section map has the shape:
+
+ %{name: "Orchestra", capacity: 40, row_count: 2, seats: [...]}
+
+ Returns `{:error, :invalid_layout}` when the structure is missing required
+ keys or contains invalid values.
+ """
+ @spec parse_layout(map() | nil) :: {:ok, [map()]} | {:error, :invalid_layout}
+ def parse_layout(nil), do: {:error, :invalid_layout}
+
+ def parse_layout(%{"sections" => sections}) when is_list(sections) and sections != [] do
+ sections
+ |> Enum.reduce_while({:ok, []}, fn section, {:ok, acc} ->
+ case parse_section(section) do
+ {:ok, parsed} -> {:cont, {:ok, acc ++ [parsed]}}
+ :error -> {:halt, {:error, :invalid_layout}}
+ end
+ end)
+ end
+
+ def parse_layout(_), do: {:error, :invalid_layout}
+
+ defp parse_section(%{"name" => name, "rows" => rows})
+ when is_binary(name) and name != "" and is_list(rows) and rows != [] do
+ rows
+ |> Enum.reduce_while({:ok, []}, fn row, {:ok, acc} ->
+ case parse_row(row) do
+ {:ok, seats} -> {:cont, {:ok, acc ++ seats}}
+ :error -> {:halt, :error}
+ end
+ end)
+ |> case do
+ {:ok, seats} ->
+ {:ok,
+ %{
+ name: name,
+ row_count: length(rows),
+ capacity: length(seats),
+ seats: seats
+ }}
+
+ :error ->
+ :error
+ end
+ end
+
+ defp parse_section(_), do: :error
+
+ defp parse_row(%{"label" => label, "seats" => count})
+ when is_binary(label) and label != "" and is_integer(count) and count > 0 do
+ seats =
+ Enum.map(1..count, fn n ->
+ %{
+ label: "#{label}-#{n}",
+ row: label,
+ number: n,
+ status: "available"
+ }
+ end)
+
+ {:ok, seats}
+ end
+
+ defp parse_row(_), do: :error
+
+ # ---------------------------------------------------------------------------
+ # Plan–Event Assignment
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Assigns a seating plan to an event by setting the `seating_plan_id` foreign key.
+ """
+ @spec assign_plan_to_event(integer(), integer()) ::
+ {:ok, Event.t()} | {:error, Ecto.Changeset.t() | :not_found}
+ def assign_plan_to_event(event_id, plan_id) do
+ case Repo.get(Event, event_id) do
+ nil ->
+ {:error, :not_found}
+
+ event ->
+ event
+ |> Ecto.Changeset.change(seating_plan_id: plan_id)
+ |> Repo.update()
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Section–Item Mapping
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Maps a seating section to a catalog item and optional item variation.
+ Pass `nil` for `variation_id` to clear the variation mapping.
+ """
+ @spec map_section_to_item(integer(), integer(), integer() | nil) ::
+ {:ok, SeatingSection.t()} | {:error, Ecto.Changeset.t() | :not_found}
+ def map_section_to_item(section_id, item_id, variation_id \\ nil) do
+ case Repo.get(SeatingSection, section_id) do
+ nil ->
+ {:error, :not_found}
+
+ section ->
+ section
+ |> SeatingSection.mapping_changeset(%{item_id: item_id, item_variation_id: variation_id})
+ |> Repo.update()
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Seat Availability
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Returns all seats in a section that are available for a given event —
+ i.e. not blocked and without an active (held or confirmed) reservation.
+ """
+ @spec available_seats(integer(), integer()) :: [Seat.t()]
+ def available_seats(event_id, section_id) do
+ active_seat_ids =
+ SeatReservation
+ |> where([r], r.event_id == ^event_id and r.status != "released")
+ |> select([r], r.seat_id)
+
+ Seat
+ |> where([s], s.seating_section_id == ^section_id)
+ |> where([s], s.status == "available")
+ |> where([s], s.id not in subquery(active_seat_ids))
+ |> order_by([s], asc: s.row, asc: s.number)
+ |> Repo.all()
+ end
+
+ # ---------------------------------------------------------------------------
+ # Seat Reservation Operations
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Temporarily holds a seat for a cart session during checkout.
+
+ The hold expires at `held_until`. Returns `{:error, :already_reserved}` if
+ the seat already has an active (held or confirmed) reservation for the event.
+ """
+ @spec hold_seat(integer(), integer(), integer()) ::
+ {:ok, SeatReservation.t()} | {:error, :already_reserved | Ecto.Changeset.t()}
+ def hold_seat(seat_id, event_id, cart_session_id) do
+ held_until = DateTime.add(DateTime.utc_now(), 15 * 60, :second) |> DateTime.truncate(:second)
+
+ %SeatReservation{}
+ |> SeatReservation.hold_changeset(%{
+ seat_id: seat_id,
+ event_id: event_id,
+ cart_session_id: cart_session_id,
+ held_until: held_until
+ })
+ |> Repo.insert()
+ |> case do
+ {:ok, reservation} -> {:ok, reservation}
+ {:error, changeset} -> handle_reservation_conflict(changeset)
+ end
+ end
+
+ @doc """
+ Upgrades a held reservation to confirmed, linking it to an order item.
+
+ Returns `{:error, :not_found}` if no active reservation exists for the seat
+ and event combination.
+ """
+ @spec confirm_seat(integer(), integer(), integer()) ::
+ {:ok, SeatReservation.t()} | {:error, :not_found | Ecto.Changeset.t()}
+ def confirm_seat(seat_id, event_id, order_item_id) do
+ case get_active_reservation(seat_id, event_id) do
+ nil ->
+ {:error, :not_found}
+
+ reservation ->
+ reservation
+ |> SeatReservation.confirm_changeset(%{order_item_id: order_item_id})
+ |> Repo.update()
+ end
+ end
+
+ @doc """
+ Releases a held or confirmed reservation, making the seat available again.
+
+ Returns `{:error, :not_found}` if no active reservation exists.
+ """
+ @spec release_seat(integer(), integer()) ::
+ {:ok, SeatReservation.t()} | {:error, :not_found | Ecto.Changeset.t()}
+ def release_seat(seat_id, event_id) do
+ case get_active_reservation(seat_id, event_id) do
+ nil -> {:error, :not_found}
+ reservation -> reservation |> SeatReservation.release_changeset() |> Repo.update()
+ end
+ end
+
+ @doc """
+ Manually assigns a seat to an order item, bypassing the cart hold flow.
+ Used by organizers for manual seat assignment.
+
+ Returns `{:error, :already_reserved}` if the seat is already taken.
+ """
+ @spec assign_seat(integer(), integer(), integer()) ::
+ {:ok, SeatReservation.t()} | {:error, :already_reserved | Ecto.Changeset.t()}
+ def assign_seat(seat_id, event_id, order_item_id) do
+ %SeatReservation{}
+ |> SeatReservation.assign_changeset(%{
+ seat_id: seat_id,
+ event_id: event_id,
+ order_item_id: order_item_id
+ })
+ |> Repo.insert()
+ |> case do
+ {:ok, reservation} -> {:ok, reservation}
+ {:error, changeset} -> handle_reservation_conflict(changeset)
+ end
+ end
+
+ @doc """
+ Reassigns a seat from one seat to another for an existing order item.
+
+ If the attendee for the order item has already checked in, the function
+ still performs the reassignment but returns `{:ok, reservation, :checked_in_warning}`
+ to alert the caller.
+
+ Returns `{:error, :not_found}` if the old seat has no active reservation for
+ the event, or `{:error, :already_reserved}` if the new seat is taken.
+ """
+ @spec reassign_seat(integer(), integer(), integer(), integer()) ::
+ {:ok, SeatReservation.t()}
+ | {:ok, SeatReservation.t(), :checked_in_warning}
+ | {:error, :not_found | :already_reserved | Ecto.Changeset.t()}
+ def reassign_seat(old_seat_id, new_seat_id, event_id, order_item_id) do
+ with {:old, reservation} when not is_nil(reservation) <-
+ {:old, get_active_reservation(old_seat_id, event_id)},
+ {:new_free} <- check_seat_free(new_seat_id, event_id) do
+ checked_in? = has_check_in?(reservation.order_item_id)
+
+ Repo.transaction(fn ->
+ reservation |> SeatReservation.release_changeset() |> Repo.update!()
+
+ new_reservation =
+ %SeatReservation{}
+ |> Ecto.Changeset.change(%{
+ seat_id: new_seat_id,
+ event_id: event_id,
+ order_item_id: order_item_id,
+ status: "confirmed"
+ })
+ |> Repo.insert!()
+
+ {new_reservation, checked_in?}
+ end)
+ |> case do
+ {:ok, {reservation, true}} -> {:ok, reservation, :checked_in_warning}
+ {:ok, {reservation, false}} -> {:ok, reservation}
+ {:error, reason} -> {:error, reason}
+ end
+ else
+ {:old, nil} -> {:error, :not_found}
+ {:new_taken} -> {:error, :already_reserved}
+ end
+ end
+
+ @doc """
+ Releases all held reservations whose `held_until` timestamp is in the past.
+
+ Intended to be called periodically (e.g. via an Oban worker or scheduled task).
+ Returns the count of reservations released.
+ """
+ @spec release_expired_holds() :: {integer(), nil}
+ def release_expired_holds do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ SeatReservation
+ |> where([r], r.status == "held" and r.held_until < ^now)
+ |> Repo.update_all(set: [status: "released", held_until: nil])
+ end
+
+ # ---------------------------------------------------------------------------
+ # Private helpers
+ # ---------------------------------------------------------------------------
+
+ defp get_active_reservation(seat_id, event_id) do
+ SeatReservation
+ |> where([r], r.seat_id == ^seat_id and r.event_id == ^event_id and r.status != "released")
+ |> Repo.one()
+ end
+
+ defp check_seat_free(seat_id, event_id) do
+ case get_active_reservation(seat_id, event_id) do
+ nil -> {:new_free}
+ _reservation -> {:new_taken}
+ end
+ end
+
+ defp has_check_in?(nil), do: false
+
+ defp has_check_in?(order_item_id) do
+ Repo.exists?(
+ from c in "check_ins",
+ where: c.order_item_id == ^order_item_id and is_nil(c.annulled_at)
+ )
+ end
+
+ defp handle_reservation_conflict(%Ecto.Changeset{} = changeset) do
+ if unique_constraint_error?(changeset, :seat_id) do
+ {:error, :already_reserved}
+ else
+ {:error, changeset}
+ end
+ end
+
+ defp unique_constraint_error?(%Ecto.Changeset{errors: errors}, field) do
+ Enum.any?(errors, fn
+ {^field, {_msg, opts}} -> Keyword.get(opts, :constraint) == :unique
+ _ -> false
+ end)
+ end
+end
diff --git a/pretex/lib/pretex/seating/seat.ex b/pretex/lib/pretex/seating/seat.ex
new file mode 100644
index 0000000..38a736c
--- /dev/null
+++ b/pretex/lib/pretex/seating/seat.ex
@@ -0,0 +1,56 @@
+defmodule Pretex.Seating.Seat do
+ @moduledoc """
+ An individual seat within a seating section.
+
+ Seats are identified by their row label and number (e.g. row "A", number 5),
+ and carry a human-readable label (e.g. "A-5"). The status field reflects
+ whether the seat is available for selection or permanently blocked by the organizer.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @statuses ~w(available blocked)
+
+ @type t :: %__MODULE__{
+ id: integer() | nil,
+ label: String.t() | nil,
+ row: String.t() | nil,
+ number: integer() | nil,
+ status: String.t() | nil,
+ seating_section_id: integer() | nil,
+ seating_section: Pretex.Seating.SeatingSection.t() | Ecto.Association.NotLoaded.t(),
+ reservations: [Pretex.Seating.SeatReservation.t()] | Ecto.Association.NotLoaded.t(),
+ inserted_at: DateTime.t() | nil,
+ updated_at: DateTime.t() | nil
+ }
+
+ schema "seats" do
+ field :label, :string
+ field :row, :string
+ field :number, :integer
+ field :status, :string, default: "available"
+
+ belongs_to :seating_section, Pretex.Seating.SeatingSection
+ has_many :reservations, Pretex.Seating.SeatReservation
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc "Changeset for creating a seat."
+ @spec changeset(t(), map()) :: Ecto.Changeset.t()
+ def changeset(seat, attrs) do
+ seat
+ |> cast(attrs, [:label, :row, :number, :status, :seating_section_id])
+ |> validate_required([:label, :row, :number, :status, :seating_section_id])
+ |> validate_length(:label, min: 1, max: 20)
+ |> validate_length(:row, min: 1, max: 10)
+ |> validate_number(:number, greater_than: 0)
+ |> validate_inclusion(:status, @statuses)
+ |> unique_constraint([:seating_section_id, :row, :number])
+ end
+
+ @doc "Returns the list of valid seat statuses."
+ @spec statuses() :: [String.t()]
+ def statuses, do: @statuses
+end
diff --git a/pretex/lib/pretex/seating/seat_reservation.ex b/pretex/lib/pretex/seating/seat_reservation.ex
new file mode 100644
index 0000000..60b6ef0
--- /dev/null
+++ b/pretex/lib/pretex/seating/seat_reservation.ex
@@ -0,0 +1,93 @@
+defmodule Pretex.Seating.SeatReservation do
+ @moduledoc """
+ Tracks seat reservation state for a specific event.
+
+ A reservation moves through three states:
+ - `held` — temporarily reserved during checkout; expires at `held_until`
+ - `confirmed` — permanently assigned to an order item after payment
+ - `released` — freed (cart expired, order cancelled, or manual release)
+
+ The partial unique index on `[seat_id, event_id]` where `status != 'released'`
+ enforces that only one active reservation exists per seat per event at any time.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @statuses ~w(held confirmed released)
+
+ @type t :: %__MODULE__{
+ id: integer() | nil,
+ status: String.t() | nil,
+ held_until: DateTime.t() | nil,
+ seat_id: integer() | nil,
+ event_id: integer() | nil,
+ order_item_id: integer() | nil,
+ cart_session_id: integer() | nil,
+ seat: Pretex.Seating.Seat.t() | Ecto.Association.NotLoaded.t(),
+ event: Pretex.Events.Event.t() | Ecto.Association.NotLoaded.t(),
+ order_item: Pretex.Orders.OrderItem.t() | nil | Ecto.Association.NotLoaded.t(),
+ cart_session: Pretex.Orders.CartSession.t() | nil | Ecto.Association.NotLoaded.t(),
+ inserted_at: DateTime.t() | nil,
+ updated_at: DateTime.t() | nil
+ }
+
+ schema "seat_reservations" do
+ field :status, :string, default: "held"
+ field :held_until, :utc_datetime
+
+ belongs_to :seat, Pretex.Seating.Seat
+ belongs_to :event, Pretex.Events.Event
+ belongs_to :order_item, Pretex.Orders.OrderItem
+ belongs_to :cart_session, Pretex.Orders.CartSession
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc "Changeset for creating a held reservation during checkout."
+ @spec hold_changeset(t(), map()) :: Ecto.Changeset.t()
+ def hold_changeset(reservation, attrs) do
+ reservation
+ |> cast(attrs, [:seat_id, :event_id, :cart_session_id, :held_until, :status])
+ |> validate_required([:seat_id, :event_id, :cart_session_id, :held_until])
+ |> put_change(:status, "held")
+ |> unique_constraint([:seat_id, :event_id],
+ name: :seat_reservations_seat_id_event_id_active_index,
+ message: "assento já reservado para este evento"
+ )
+ end
+
+ @doc "Changeset for confirming a reservation after order payment."
+ @spec confirm_changeset(t(), map()) :: Ecto.Changeset.t()
+ def confirm_changeset(reservation, attrs) do
+ reservation
+ |> cast(attrs, [:order_item_id])
+ |> validate_required([:order_item_id])
+ |> put_change(:status, "confirmed")
+ |> put_change(:held_until, nil)
+ |> put_change(:cart_session_id, nil)
+ end
+
+ @doc "Changeset for directly assigning a confirmed reservation (organizer manual assignment)."
+ @spec assign_changeset(t(), map()) :: Ecto.Changeset.t()
+ def assign_changeset(reservation, attrs) do
+ reservation
+ |> cast(attrs, [:seat_id, :event_id, :order_item_id])
+ |> validate_required([:seat_id, :event_id, :order_item_id])
+ |> put_change(:status, "confirmed")
+ |> unique_constraint([:seat_id, :event_id],
+ name: :seat_reservations_seat_id_event_id_active_index,
+ message: "assento já reservado para este evento"
+ )
+ end
+
+ @doc "Changeset for releasing a reservation (cart expired or order cancelled)."
+ @spec release_changeset(t()) :: Ecto.Changeset.t()
+ def release_changeset(reservation) do
+ change(reservation, status: "released", held_until: nil)
+ end
+
+ @doc "Returns the list of valid reservation statuses."
+ @spec statuses() :: [String.t()]
+ def statuses, do: @statuses
+end
diff --git a/pretex/lib/pretex/seating/seating_plan.ex b/pretex/lib/pretex/seating/seating_plan.ex
new file mode 100644
index 0000000..d7d991d
--- /dev/null
+++ b/pretex/lib/pretex/seating/seating_plan.ex
@@ -0,0 +1,42 @@
+defmodule Pretex.Seating.SeatingPlan do
+ @moduledoc """
+ Represents a venue seating plan consisting of named sections, rows, and seats.
+
+ A seating plan belongs to an organization and may be assigned to multiple events.
+ The `layout` field stores the original uploaded JSON structure for reference.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @type t :: %__MODULE__{
+ id: integer() | nil,
+ name: String.t() | nil,
+ layout: map() | nil,
+ organization_id: integer() | nil,
+ sections: [Pretex.Seating.SeatingSection.t()] | Ecto.Association.NotLoaded.t(),
+ events: [Pretex.Events.Event.t()] | Ecto.Association.NotLoaded.t(),
+ inserted_at: DateTime.t() | nil,
+ updated_at: DateTime.t() | nil
+ }
+
+ schema "seating_plans" do
+ field :name, :string
+ field :layout, :map
+
+ belongs_to :organization, Pretex.Organizations.Organization
+ has_many :sections, Pretex.Seating.SeatingSection
+ has_many :events, Pretex.Events.Event
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc "Changeset for creating or updating a seating plan."
+ @spec changeset(t(), map()) :: Ecto.Changeset.t()
+ def changeset(plan, attrs) do
+ plan
+ |> cast(attrs, [:name, :layout, :organization_id])
+ |> validate_required([:name, :layout, :organization_id])
+ |> validate_length(:name, min: 2, max: 255)
+ end
+end
diff --git a/pretex/lib/pretex/seating/seating_section.ex b/pretex/lib/pretex/seating/seating_section.ex
new file mode 100644
index 0000000..f49ce81
--- /dev/null
+++ b/pretex/lib/pretex/seating/seating_section.ex
@@ -0,0 +1,58 @@
+defmodule Pretex.Seating.SeatingSection do
+ @moduledoc """
+ A named section within a seating plan (e.g. "Orchestra", "Balcony").
+
+ Sections contain individual seats and may be mapped to a catalog item and optional
+ item variation, allowing organizers to sell different ticket types per section.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @type t :: %__MODULE__{
+ id: integer() | nil,
+ name: String.t() | nil,
+ row_count: integer() | nil,
+ capacity: integer() | nil,
+ seating_plan_id: integer() | nil,
+ item_id: integer() | nil,
+ item_variation_id: integer() | nil,
+ seating_plan: Pretex.Seating.SeatingPlan.t() | Ecto.Association.NotLoaded.t(),
+ seats: [Pretex.Seating.Seat.t()] | Ecto.Association.NotLoaded.t(),
+ item: Pretex.Catalog.Item.t() | nil | Ecto.Association.NotLoaded.t(),
+ item_variation: Pretex.Catalog.ItemVariation.t() | nil | Ecto.Association.NotLoaded.t(),
+ inserted_at: DateTime.t() | nil,
+ updated_at: DateTime.t() | nil
+ }
+
+ schema "seating_sections" do
+ field :name, :string
+ field :row_count, :integer
+ field :capacity, :integer
+
+ belongs_to :seating_plan, Pretex.Seating.SeatingPlan
+ belongs_to :item, Pretex.Catalog.Item
+ belongs_to :item_variation, Pretex.Catalog.ItemVariation
+
+ has_many :seats, Pretex.Seating.Seat
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc "Changeset for creating a seating section."
+ @spec changeset(t(), map()) :: Ecto.Changeset.t()
+ def changeset(section, attrs) do
+ section
+ |> cast(attrs, [:name, :row_count, :capacity, :seating_plan_id, :item_id, :item_variation_id])
+ |> validate_required([:name, :capacity, :seating_plan_id])
+ |> validate_length(:name, min: 1, max: 255)
+ |> validate_number(:capacity, greater_than: 0)
+ end
+
+ @doc "Changeset for mapping a section to a catalog item and optional variation."
+ @spec mapping_changeset(t(), map()) :: Ecto.Changeset.t()
+ def mapping_changeset(section, attrs) do
+ section
+ |> cast(attrs, [:item_id, :item_variation_id])
+ end
+end
diff --git a/pretex/lib/pretex_web/live/admin/seating_live/index.ex b/pretex/lib/pretex_web/live/admin/seating_live/index.ex
new file mode 100644
index 0000000..85c987a
--- /dev/null
+++ b/pretex/lib/pretex_web/live/admin/seating_live/index.ex
@@ -0,0 +1,150 @@
+defmodule PretexWeb.Admin.SeatingLive.Index do
+ @moduledoc """
+ Lists seating plans for an organization and allows uploading a new plan via
+ JSON layout.
+ """
+
+ use PretexWeb, :live_view
+
+ alias Pretex.Seating
+ alias Pretex.Seating.SeatingPlan
+ alias Pretex.Organizations
+
+ @impl true
+ def mount(%{"org_id" => org_id}, _session, socket) do
+ org = Organizations.get_organization!(org_id)
+ plans = Seating.list_seating_plans(org.id)
+
+ socket =
+ socket
+ |> assign(:org, org)
+ |> assign(:page_title, "Plantas de Assentos — #{org.name}")
+ |> stream(:plans, plans)
+ |> allow_upload(:layout,
+ accept: ~w(.json),
+ max_entries: 1,
+ max_file_size: 1_000_000
+ )
+
+ {: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(:upload_error, nil)
+ end
+
+ defp apply_action(socket, :new, _params) do
+ socket
+ |> assign(:page_title, "Nova Planta de Assentos")
+ |> assign(:form, to_form(Seating.change_seating_plan(%SeatingPlan{})))
+ |> assign(:upload_error, nil)
+ end
+
+ @impl true
+ def handle_event("validate", %{"seating_plan" => params}, socket) do
+ changeset =
+ %SeatingPlan{}
+ |> Seating.change_seating_plan(params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, :form, to_form(changeset))}
+ end
+
+ def handle_event("save", %{"seating_plan" => params}, socket) do
+ org = socket.assigns.org
+
+ case consume_layout_upload(socket) do
+ {:ok, layout} ->
+ attrs = Map.put(params, "layout", layout)
+
+ case Seating.create_seating_plan(org.id, attrs) do
+ {:ok, plan} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Planta \"#{plan.name}\" criada com sucesso.")
+ |> stream_insert(:plans, plan)
+ |> push_patch(to: ~p"/admin/organizations/#{org}/seating")}
+
+ {:error, :invalid_layout} ->
+ {:noreply, assign(socket, :upload_error, "O arquivo JSON tem formato inválido.")}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :form, to_form(changeset))}
+ end
+
+ {:error, message} ->
+ {:noreply, assign(socket, :upload_error, message)}
+ end
+ end
+
+ def handle_event("cancel_upload", %{"ref" => ref}, socket) do
+ {:noreply, cancel_upload(socket, :layout, ref)}
+ end
+
+ def handle_event("cancel", _params, socket) do
+ org = socket.assigns.org
+ {:noreply, push_patch(socket, to: ~p"/admin/organizations/#{org}/seating")}
+ end
+
+ def handle_event("delete", %{"id" => id}, socket) do
+ plan = Seating.get_seating_plan!(id)
+
+ case Seating.delete_seating_plan(plan) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Planta removida.")
+ |> stream_delete(:plans, plan)}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Não foi possível remover a planta.")}
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Private helpers
+ # ---------------------------------------------------------------------------
+
+ defp consume_layout_upload(socket) do
+ entries = socket.assigns.uploads.layout.entries
+
+ case entries do
+ [] ->
+ {:error, "Selecione um arquivo JSON de layout."}
+
+ [_entry] ->
+ result =
+ consume_uploaded_entries(socket, :layout, fn %{path: path}, _entry ->
+ case File.read(path) do
+ {:ok, content} ->
+ case Jason.decode(content) do
+ {:ok, json} -> {:ok, json}
+ {:error, _} -> {:ok, :invalid_json}
+ end
+
+ {:error, _} ->
+ {:ok, :read_error}
+ end
+ end)
+
+ case result do
+ [:invalid_json] -> {:error, "O arquivo não é um JSON válido."}
+ [:read_error] -> {:error, "Erro ao ler o arquivo."}
+ [json] -> {:ok, json}
+ _ -> {:error, "Erro inesperado ao processar o arquivo."}
+ end
+ end
+ end
+
+ defp upload_error_to_string(:too_large), do: "Arquivo muito grande (máximo 1 MB)."
+ defp upload_error_to_string(:not_accepted), do: "Formato não aceito. Envie um arquivo .json."
+ defp upload_error_to_string(:too_many_files), do: "Envie apenas um arquivo por vez."
+ defp upload_error_to_string(_), do: "Erro ao processar o arquivo."
+end
diff --git a/pretex/lib/pretex_web/live/admin/seating_live/index.html.heex b/pretex/lib/pretex_web/live/admin/seating_live/index.html.heex
new file mode 100644
index 0000000..03ef132
--- /dev/null
+++ b/pretex/lib/pretex_web/live/admin/seating_live/index.html.heex
@@ -0,0 +1,150 @@
+<.dashboard_layout
+ current_path={~p"/admin/organizations/#{@org}/seating"}
+ org={@org}
+ flash={@flash}
+>
+
+ <%!-- Barra superior --%>
+
+
+
Plantas de Assentos
+
{@org.name}
+
+ <.link
+ patch={~p"/admin/organizations/#{@org}/seating/new"}
+ class="btn btn-primary btn-sm gap-1"
+ >
+ <.icon name="hero-plus" class="size-4" /> Nova Planta
+
+
+
+ <%!-- Formulário de upload --%>
+
+
+
Carregar Nova Planta
+
+ <.form for={@form} phx-change="validate" phx-submit="save">
+
+
+
+ <.input
+ field={@form[:name]}
+ type="text"
+ placeholder="Ex: Teatro Municipal"
+ class="input input-bordered w-full"
+ />
+
+
+
+
+
+ <.icon name="hero-arrow-up-tray" class="size-8 text-base-content/30 mx-auto mb-2" />
+
+ Arraste um arquivo JSON ou clique para selecionar
+
+ <.live_file_input upload={@uploads.layout} class="hidden" />
+
+
+
+ <%!-- Preview de entradas --%>
+
+ <%= for entry <- @uploads.layout.entries do %>
+
+ <.icon name="hero-document-text" class="size-4 text-base-content/50 shrink-0" />
+ {entry.client_name}
+
+
+ <%= for err <- upload_errors(@uploads.layout, entry) do %>
+
{upload_error_to_string(err)}
+ <% end %>
+ <% end %>
+
+
+
{@upload_error}
+
+
+
+
+
+
+
+
+
+
+
+ <%!-- Lista de plantas --%>
+
+
+ <.icon name="hero-map" class="size-12 text-base-content/20 mb-4" />
+
Nenhuma planta ainda.
+
+ Carregue uma planta de assentos em formato JSON para começar.
+
+ <.link
+ patch={~p"/admin/organizations/#{@org}/seating/new"}
+ class="btn btn-primary btn-sm mt-6 gap-1"
+ >
+ <.icon name="hero-plus" class="size-4" /> Nova Planta
+
+
+
+
+
+
+
{plan.name}
+
+ Criada em {Calendar.strftime(plan.inserted_at, "%d/%m/%Y")}
+
+
+
+
+ <.link
+ navigate={~p"/admin/organizations/#{@org}/seating/#{plan}"}
+ class="btn btn-ghost btn-sm"
+ >
+ Ver detalhes
+
+
+
+
+
+
+
+
diff --git a/pretex/lib/pretex_web/live/admin/seating_live/show.ex b/pretex/lib/pretex_web/live/admin/seating_live/show.ex
new file mode 100644
index 0000000..0fb2e73
--- /dev/null
+++ b/pretex/lib/pretex_web/live/admin/seating_live/show.ex
@@ -0,0 +1,155 @@
+defmodule PretexWeb.Admin.SeatingLive.Show do
+ @moduledoc """
+ Displays the details of a seating plan: its sections, seat counts, and
+ item mappings. Allows organizers to:
+ - Map sections to catalog items/variations.
+ - Assign the plan to an event.
+ """
+
+ use PretexWeb, :live_view
+
+ alias Pretex.Events
+ alias Pretex.Organizations
+ alias Pretex.Seating
+
+ @impl true
+ def mount(%{"org_id" => org_id, "id" => plan_id}, _session, socket) do
+ org = Organizations.get_organization!(org_id)
+ plan = Seating.get_seating_plan!(plan_id)
+ events = Events.list_events(org)
+
+ socket =
+ socket
+ |> assign(:org, org)
+ |> assign(:plan, plan)
+ |> assign(:events, events)
+ |> assign(:page_title, "Planta — #{plan.name}")
+ |> assign(:mapping_section_id, nil)
+ |> assign(:mapping_form, nil)
+ |> assign(:assign_event_form, nil)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_params(_params, _url, socket) do
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("map_section", %{"section_id" => section_id_str}, socket) do
+ section_id = String.to_integer(section_id_str)
+ org = socket.assigns.org
+ event_items = list_items_for_org(org.id)
+
+ {:noreply,
+ socket
+ |> assign(:mapping_section_id, section_id)
+ |> assign(:event_items, event_items)
+ |> assign(:mapping_form, to_form(%{"item_id" => "", "item_variation_id" => ""}))}
+ end
+
+ def handle_event("cancel_mapping", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:mapping_section_id, nil)
+ |> assign(:mapping_form, nil)}
+ end
+
+ def handle_event(
+ "save_mapping",
+ %{"mapping" => %{"item_id" => item_id_str, "item_variation_id" => var_id_str}},
+ socket
+ ) do
+ section_id = socket.assigns.mapping_section_id
+ item_id = parse_optional_id(item_id_str)
+ variation_id = parse_optional_id(var_id_str)
+
+ case Seating.map_section_to_item(section_id, item_id, variation_id) do
+ {:ok, _section} ->
+ plan = Seating.get_seating_plan!(socket.assigns.plan.id)
+
+ {:noreply,
+ socket
+ |> assign(:plan, plan)
+ |> assign(:mapping_section_id, nil)
+ |> assign(:mapping_form, nil)
+ |> put_flash(:info, "Seção mapeada com sucesso.")}
+
+ {:error, :not_found} ->
+ {:noreply, put_flash(socket, :error, "Seção não encontrada.")}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Erro ao salvar mapeamento.")}
+ end
+ end
+
+ def handle_event("show_assign_event", _params, socket) do
+ {:noreply, assign(socket, :assign_event_form, to_form(%{"event_id" => ""}))}
+ end
+
+ def handle_event("cancel_assign_event", _params, socket) do
+ {:noreply, assign(socket, :assign_event_form, nil)}
+ end
+
+ def handle_event("assign_to_event", %{"assignment" => %{"event_id" => event_id_str}}, socket) do
+ plan = socket.assigns.plan
+
+ case parse_optional_id(event_id_str) do
+ nil ->
+ {:noreply, put_flash(socket, :error, "Selecione um evento.")}
+
+ event_id ->
+ case Seating.assign_plan_to_event(event_id, plan.id) do
+ {:ok, _event} ->
+ {:noreply,
+ socket
+ |> assign(:assign_event_form, nil)
+ |> put_flash(:info, "Planta atribuída ao evento com sucesso.")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Erro ao atribuir a planta ao evento.")}
+ end
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Private helpers
+ # ---------------------------------------------------------------------------
+
+ defp list_items_for_org(org_id) do
+ alias Pretex.Repo
+ import Ecto.Query
+
+ Pretex.Catalog.Item
+ |> join(:inner, [i], e in Pretex.Events.Event, on: i.event_id == e.id)
+ |> where([_i, e], e.organization_id == ^org_id)
+ |> where([i, _e], i.item_type == "ticket" and i.status == "active")
+ |> preload(:variations)
+ |> Repo.all()
+ end
+
+ defp parse_optional_id(""), do: nil
+ defp parse_optional_id(nil), do: nil
+
+ defp parse_optional_id(str) when is_binary(str) do
+ case Integer.parse(str) do
+ {id, ""} -> id
+ _ -> nil
+ end
+ end
+
+ defp parse_optional_id(id) when is_integer(id), do: id
+
+ defp section_item_label(nil), do: "—"
+
+ defp section_item_label(item) do
+ item.name
+ end
+
+ defp section_variation_label(nil), do: nil
+
+ defp section_variation_label(variation) do
+ variation.name
+ end
+end
diff --git a/pretex/lib/pretex_web/live/admin/seating_live/show.html.heex b/pretex/lib/pretex_web/live/admin/seating_live/show.html.heex
new file mode 100644
index 0000000..13d6332
--- /dev/null
+++ b/pretex/lib/pretex_web/live/admin/seating_live/show.html.heex
@@ -0,0 +1,155 @@
+<.dashboard_layout
+ current_path={~p"/admin/organizations/#{@org}/seating"}
+ org={@org}
+ flash={@flash}
+>
+
+ <%!-- Barra superior --%>
+
+ <.link
+ navigate={~p"/admin/organizations/#{@org}/seating"}
+ 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 às Plantas
+
+
+
+
+
+ <%!-- Cabeçalho da planta --%>
+
+
{@plan.name}
+
+ Criada em {Calendar.strftime(@plan.inserted_at, "%d/%m/%Y")}
+ · {@plan.sections |> length()} seções
+ · {@plan.sections |> Enum.flat_map(& &1.seats) |> length()} assentos no total
+
+
+
+ <%!-- Modal de atribuição a evento --%>
+
+
+
Atribuir Planta a Evento
+ <.form for={@assign_event_form} phx-submit="assign_to_event">
+
+
+
+
+
+
+
+
+
+
+
+
+ <%!-- Seções --%>
+
+ <%= for section <- @plan.sections do %>
+
+
+ <%!-- Cabeçalho da seção --%>
+
+
+
{section.name}
+
+ {section.capacity} assentos · {section.row_count || 0} fileiras
+
+
+
+
+ <%!-- Mapeamento atual --%>
+
+
Tipo de ingresso
+
+ {section_item_label(section.item)}
+ <%= if variation = section_variation_label(section.item_variation) do %>
+ · {variation}
+ <% end %>
+
+
+
+
+
+
+
+ <%!-- Formulário de mapeamento inline --%>
+
+
+ Mapear seção a um tipo de ingresso
+
+ <.form for={@mapping_form} phx-submit="save_mapping">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%!-- Grade de assentos --%>
+
+ <%= for seat <- section.seats do %>
+
+ {seat.label}
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+
diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex
index f73db44..8ea18b2 100644
--- a/pretex/lib/pretex_web/router.ex
+++ b/pretex/lib/pretex_web/router.ex
@@ -205,6 +205,10 @@ defmodule PretexWeb.Router do
live("/organizations/:org_id/memberships/:id/grant", MembershipLive.Index, :grant)
live("/organizations/:org_id/devices", DeviceLive.Index, :index)
+
+ live("/organizations/:org_id/seating", SeatingLive.Index, :index)
+ live("/organizations/:org_id/seating/new", SeatingLive.Index, :new)
+ live("/organizations/:org_id/seating/:id", SeatingLive.Show, :show)
end
end
diff --git a/pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs b/pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs
new file mode 100644
index 0000000..cba989b
--- /dev/null
+++ b/pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs
@@ -0,0 +1,68 @@
+defmodule Pretex.Repo.Migrations.CreateSeatingPlans do
+ use Ecto.Migration
+
+ def change do
+ create table(:seating_plans) do
+ add :name, :string, null: false
+ add :layout, :map, null: false
+ add :organization_id, references(:organizations, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:seating_plans, [:organization_id])
+
+ create table(:seating_sections) do
+ add :name, :string, null: false
+ add :row_count, :integer
+ add :capacity, :integer, null: false
+ add :seating_plan_id, references(:seating_plans, on_delete: :delete_all), null: false
+ add :item_id, references(:items, on_delete: :nilify_all)
+ add :item_variation_id, references(:item_variations, on_delete: :nilify_all)
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:seating_sections, [:seating_plan_id])
+ create index(:seating_sections, [:item_id])
+
+ create table(:seats) do
+ add :label, :string, null: false
+ add :row, :string, null: false
+ add :number, :integer, null: false
+ add :status, :string, null: false, default: "available"
+ add :seating_section_id, references(:seating_sections, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:seats, [:seating_section_id])
+ create unique_index(:seats, [:seating_section_id, :row, :number])
+
+ create table(:seat_reservations) do
+ add :status, :string, null: false, default: "held"
+ add :held_until, :utc_datetime
+ add :seat_id, references(:seats, on_delete: :delete_all), null: false
+ add :event_id, references(:events, on_delete: :delete_all), null: false
+ add :order_item_id, references(:order_items, on_delete: :nilify_all)
+ add :cart_session_id, references(:cart_sessions, on_delete: :nilify_all)
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:seat_reservations, [:seat_id])
+ create index(:seat_reservations, [:event_id])
+ create index(:seat_reservations, [:cart_session_id])
+ create index(:seat_reservations, [:order_item_id])
+
+ # Partial unique index: only one active (held/confirmed) reservation per seat per event
+ create unique_index(:seat_reservations, [:seat_id, :event_id],
+ where: "status != 'released'",
+ name: :seat_reservations_seat_id_event_id_active_index
+ )
+
+ alter table(:events) do
+ add :seating_plan_id, references(:seating_plans, on_delete: :nilify_all)
+ end
+ end
+end
diff --git a/pretex/test/pretex/seating_test.exs b/pretex/test/pretex/seating_test.exs
new file mode 100644
index 0000000..7635318
--- /dev/null
+++ b/pretex/test/pretex/seating_test.exs
@@ -0,0 +1,701 @@
+defmodule Pretex.SeatingTest do
+ use Pretex.DataCase, async: true
+
+ import Pretex.OrganizationsFixtures
+ import Pretex.EventsFixtures
+ import Pretex.CatalogFixtures
+ import Pretex.SeatingFixtures
+
+ alias Pretex.Seating
+ alias Pretex.Seating.SeatReservation
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ defp cart_session_fixture(event) do
+ {:ok, cart} = Pretex.Orders.create_cart(event)
+ cart
+ end
+
+ defp order_item_fixture(event) do
+ item = item_fixture(event)
+
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+ confirmation_code = "TEST#{System.unique_integer([:positive])}"
+
+ {:ok, order} =
+ %Pretex.Orders.Order{}
+ |> Ecto.Changeset.change(%{
+ event_id: event.id,
+ status: "confirmed",
+ total_cents: item.price_cents,
+ email: "test@example.com",
+ name: "Test User",
+ confirmation_code: confirmation_code,
+ expires_at: DateTime.add(now, 3600, :second)
+ })
+ |> Pretex.Repo.insert()
+
+ ticket_code = "TKT#{System.unique_integer([:positive])}"
+
+ {:ok, order_item} =
+ %Pretex.Orders.OrderItem{}
+ |> Ecto.Changeset.change(%{
+ order_id: order.id,
+ item_id: item.id,
+ quantity: 1,
+ unit_price_cents: item.price_cents,
+ ticket_code: ticket_code
+ })
+ |> Pretex.Repo.insert()
+
+ order_item
+ end
+
+ # ---------------------------------------------------------------------------
+ # parse_layout/1
+ # ---------------------------------------------------------------------------
+
+ describe "parse_layout/1" do
+ test "parses a valid layout into section/seat maps" do
+ layout = %{
+ "sections" => [
+ %{
+ "name" => "Pista",
+ "rows" => [
+ %{"label" => "A", "seats" => 3},
+ %{"label" => "B", "seats" => 2}
+ ]
+ }
+ ]
+ }
+
+ assert {:ok, [section]} = Seating.parse_layout(layout)
+ assert section.name == "Pista"
+ assert section.row_count == 2
+ assert section.capacity == 5
+ assert length(section.seats) == 5
+
+ labels = Enum.map(section.seats, & &1.label)
+ assert "A-1" in labels
+ assert "A-3" in labels
+ assert "B-1" in labels
+ assert "B-2" in labels
+ end
+
+ test "parses multiple sections" do
+ assert {:ok, sections} = Seating.parse_layout(valid_layout())
+ assert length(sections) == 2
+ names = Enum.map(sections, & &1.name)
+ assert "Orchestra" in names
+ assert "Balcony" in names
+ end
+
+ test "returns error for nil" do
+ assert {:error, :invalid_layout} = Seating.parse_layout(nil)
+ end
+
+ test "returns error for empty map" do
+ assert {:error, :invalid_layout} = Seating.parse_layout(%{})
+ end
+
+ test "returns error when sections key is missing" do
+ assert {:error, :invalid_layout} = Seating.parse_layout(%{"name" => "Test"})
+ end
+
+ test "returns error when sections is an empty list" do
+ assert {:error, :invalid_layout} = Seating.parse_layout(%{"sections" => []})
+ end
+
+ test "returns error when a section is missing name" do
+ layout = %{
+ "sections" => [
+ %{"rows" => [%{"label" => "A", "seats" => 5}]}
+ ]
+ }
+
+ assert {:error, :invalid_layout} = Seating.parse_layout(layout)
+ end
+
+ test "returns error when a section has empty name" do
+ layout = %{
+ "sections" => [
+ %{"name" => "", "rows" => [%{"label" => "A", "seats" => 5}]}
+ ]
+ }
+
+ assert {:error, :invalid_layout} = Seating.parse_layout(layout)
+ end
+
+ test "returns error when a row has invalid seat count" do
+ layout = %{
+ "sections" => [
+ %{"name" => "A", "rows" => [%{"label" => "A", "seats" => 0}]}
+ ]
+ }
+
+ assert {:error, :invalid_layout} = Seating.parse_layout(layout)
+ end
+
+ test "returns error when a row is missing label" do
+ layout = %{
+ "sections" => [
+ %{"name" => "A", "rows" => [%{"seats" => 5}]}
+ ]
+ }
+
+ assert {:error, :invalid_layout} = Seating.parse_layout(layout)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # create_seating_plan/2
+ # ---------------------------------------------------------------------------
+
+ describe "create_seating_plan/2" do
+ test "creates plan with sections and seats from valid layout" do
+ org = org_fixture()
+
+ assert {:ok, plan} =
+ Seating.create_seating_plan(org.id, %{
+ name: "Teatro Central",
+ layout: valid_layout()
+ })
+
+ assert plan.name == "Teatro Central"
+ assert plan.organization_id == org.id
+ assert length(plan.sections) == 2
+
+ total_seats = plan.sections |> Enum.flat_map(& &1.seats) |> length()
+ # Orchestra: 5+5=10, Balcony: 3
+ assert total_seats == 13
+ end
+
+ test "returns :invalid_layout for malformed JSON map" do
+ org = org_fixture()
+
+ assert {:error, :invalid_layout} =
+ Seating.create_seating_plan(org.id, %{
+ name: "Bad Plan",
+ layout: %{"wrong" => "structure"}
+ })
+ end
+
+ test "returns changeset error when name is missing" do
+ org = org_fixture()
+
+ assert {:error, changeset} =
+ Seating.create_seating_plan(org.id, %{layout: valid_layout()})
+
+ assert %{name: [_ | _]} = errors_on(changeset)
+ end
+
+ test "returns changeset error when name is too short" do
+ org = org_fixture()
+
+ assert {:error, changeset} =
+ Seating.create_seating_plan(org.id, %{name: "X", layout: valid_layout()})
+
+ assert %{name: [_ | _]} = errors_on(changeset)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # list_seating_plans/1
+ # ---------------------------------------------------------------------------
+
+ describe "list_seating_plans/1" do
+ test "returns plans for the given org ordered by name" do
+ org = org_fixture()
+ seating_plan_fixture(org.id, %{name: "Zebra Hall"})
+ seating_plan_fixture(org.id, %{name: "Alpha Arena"})
+
+ plans = Seating.list_seating_plans(org.id)
+ names = Enum.map(plans, & &1.name)
+ assert Enum.at(names, 0) == "Alpha Arena"
+ assert Enum.at(names, 1) == "Zebra Hall"
+ end
+
+ test "does not return plans from another org" do
+ org1 = org_fixture()
+ org2 = org_fixture()
+ seating_plan_fixture(org1.id)
+
+ assert Seating.list_seating_plans(org2.id) == []
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # get_seating_plan!/1
+ # ---------------------------------------------------------------------------
+
+ describe "get_seating_plan!/1" do
+ test "returns plan with sections and seats" do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ fetched = Seating.get_seating_plan!(plan.id)
+ assert fetched.id == plan.id
+ assert length(fetched.sections) == 2
+ assert fetched.sections |> hd() |> Map.get(:seats) |> is_list()
+ end
+
+ test "raises Ecto.NoResultsError for missing id" do
+ assert_raise Ecto.NoResultsError, fn -> Seating.get_seating_plan!(0) end
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # assign_plan_to_event/2
+ # ---------------------------------------------------------------------------
+
+ describe "assign_plan_to_event/2" do
+ test "links a seating plan to an event" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+
+ assert {:ok, updated_event} = Seating.assign_plan_to_event(event.id, plan.id)
+ assert updated_event.seating_plan_id == plan.id
+ end
+
+ test "returns :not_found for missing event" do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ assert {:error, :not_found} = Seating.assign_plan_to_event(0, plan.id)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # map_section_to_item/3
+ # ---------------------------------------------------------------------------
+
+ describe "map_section_to_item/3" do
+ test "sets item_id on a section" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ item = item_fixture(event)
+
+ section = hd(plan.sections)
+
+ assert {:ok, updated} = Seating.map_section_to_item(section.id, item.id, nil)
+ assert updated.item_id == item.id
+ assert is_nil(updated.item_variation_id)
+ end
+
+ test "sets item_id and item_variation_id on a section" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ item = item_fixture(event)
+ variation = variation_fixture(item)
+
+ section = hd(plan.sections)
+
+ assert {:ok, updated} = Seating.map_section_to_item(section.id, item.id, variation.id)
+ assert updated.item_id == item.id
+ assert updated.item_variation_id == variation.id
+ end
+
+ test "clears item mapping when nil is passed" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ item = item_fixture(event)
+
+ section = hd(plan.sections)
+ {:ok, _} = Seating.map_section_to_item(section.id, item.id, nil)
+
+ assert {:ok, cleared} = Seating.map_section_to_item(section.id, nil, nil)
+ assert is_nil(cleared.item_id)
+ end
+
+ test "returns :not_found for unknown section" do
+ assert {:error, :not_found} = Seating.map_section_to_item(0, 1, nil)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # available_seats/2
+ # ---------------------------------------------------------------------------
+
+ describe "available_seats/2" do
+ test "returns all seats in a section when none are reserved" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+
+ section = hd(plan.sections)
+ seats = Seating.available_seats(event.id, section.id)
+
+ assert length(seats) == section.capacity
+ end
+
+ test "excludes held seats" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+
+ section = hd(plan.sections)
+ seat = section.seats |> hd()
+
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id)
+
+ available = Seating.available_seats(event.id, section.id)
+ available_ids = Enum.map(available, & &1.id)
+ refute seat.id in available_ids
+ end
+
+ test "excludes confirmed seats" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ order_item = order_item_fixture(event)
+
+ section = hd(plan.sections)
+ seat = section.seats |> hd()
+
+ {:ok, _} = Seating.assign_seat(seat.id, event.id, order_item.id)
+
+ available = Seating.available_seats(event.id, section.id)
+ available_ids = Enum.map(available, & &1.id)
+ refute seat.id in available_ids
+ end
+
+ test "includes seats whose reservations are released" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+
+ section = hd(plan.sections)
+ seat = section.seats |> hd()
+
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id)
+ {:ok, _} = Seating.release_seat(seat.id, event.id)
+
+ available = Seating.available_seats(event.id, section.id)
+ available_ids = Enum.map(available, & &1.id)
+ assert seat.id in available_ids
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # hold_seat/3
+ # ---------------------------------------------------------------------------
+
+ describe "hold_seat/3" do
+ test "creates a held reservation with a future expiry" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ assert {:ok, reservation} = Seating.hold_seat(seat.id, event.id, cart.id)
+ assert reservation.status == "held"
+ assert reservation.seat_id == seat.id
+ assert reservation.event_id == event.id
+ assert reservation.cart_session_id == cart.id
+ assert DateTime.compare(reservation.held_until, DateTime.utc_now()) == :gt
+ end
+
+ test "returns :already_reserved for a seat that is already held" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart1 = cart_session_fixture(event)
+ cart2 = cart_session_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart1.id)
+
+ assert {:error, :already_reserved} = Seating.hold_seat(seat.id, event.id, cart2.id)
+ end
+
+ test "allows holding a seat for a different event" do
+ org = org_fixture()
+ event1 = event_fixture(org)
+ event2 = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart1 = cart_session_fixture(event1)
+ cart2 = cart_session_fixture(event2)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ assert {:ok, _} = Seating.hold_seat(seat.id, event1.id, cart1.id)
+ assert {:ok, _} = Seating.hold_seat(seat.id, event2.id, cart2.id)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # confirm_seat/3
+ # ---------------------------------------------------------------------------
+
+ describe "confirm_seat/3" do
+ test "upgrades a held reservation to confirmed" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+ order_item = order_item_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id)
+
+ assert {:ok, confirmed} = Seating.confirm_seat(seat.id, event.id, order_item.id)
+ assert confirmed.status == "confirmed"
+ assert confirmed.order_item_id == order_item.id
+ assert is_nil(confirmed.held_until)
+ end
+
+ test "returns :not_found when no active reservation exists" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ assert {:error, :not_found} = Seating.confirm_seat(seat.id, event.id, 999)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # release_seat/2
+ # ---------------------------------------------------------------------------
+
+ describe "release_seat/2" do
+ test "releases a held reservation" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id)
+
+ assert {:ok, released} = Seating.release_seat(seat.id, event.id)
+ assert released.status == "released"
+ end
+
+ test "releases a confirmed reservation" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ order_item = order_item_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, _} = Seating.assign_seat(seat.id, event.id, order_item.id)
+
+ assert {:ok, released} = Seating.release_seat(seat.id, event.id)
+ assert released.status == "released"
+ end
+
+ test "returns :not_found when no active reservation exists" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ assert {:error, :not_found} = Seating.release_seat(seat.id, event.id)
+ end
+
+ test "allows re-holding a seat after it is released" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart1 = cart_session_fixture(event)
+ cart2 = cart_session_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart1.id)
+ {:ok, _} = Seating.release_seat(seat.id, event.id)
+
+ assert {:ok, new_hold} = Seating.hold_seat(seat.id, event.id, cart2.id)
+ assert new_hold.status == "held"
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # assign_seat/3
+ # ---------------------------------------------------------------------------
+
+ describe "assign_seat/3" do
+ test "creates a confirmed reservation directly" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ order_item = order_item_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+
+ assert {:ok, reservation} = Seating.assign_seat(seat.id, event.id, order_item.id)
+ assert reservation.status == "confirmed"
+ assert reservation.order_item_id == order_item.id
+ end
+
+ test "returns :already_reserved when seat is held by another cart" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+ order_item = order_item_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id)
+
+ assert {:error, :already_reserved} = Seating.assign_seat(seat.id, event.id, order_item.id)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # reassign_seat/4
+ # ---------------------------------------------------------------------------
+
+ describe "reassign_seat/4" do
+ test "moves a confirmed reservation to a new seat" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ order_item = order_item_fixture(event)
+
+ [seat1, seat2 | _] = plan.sections |> hd() |> Map.get(:seats)
+ {:ok, _} = Seating.assign_seat(seat1.id, event.id, order_item.id)
+
+ assert {:ok, new_reservation} =
+ Seating.reassign_seat(seat1.id, seat2.id, event.id, order_item.id)
+
+ assert new_reservation.seat_id == seat2.id
+ assert new_reservation.status == "confirmed"
+
+ # Old seat should be released (available again)
+ available = Seating.available_seats(event.id, hd(plan.sections).id)
+ available_ids = Enum.map(available, & &1.id)
+ assert seat1.id in available_ids
+ end
+
+ test "returns :not_found when old seat has no active reservation" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+
+ [seat1, seat2 | _] = plan.sections |> hd() |> Map.get(:seats)
+
+ assert {:error, :not_found} =
+ Seating.reassign_seat(seat1.id, seat2.id, event.id, 999)
+ end
+
+ test "returns :already_reserved when target seat is taken" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ order_item1 = order_item_fixture(event)
+ order_item2 = order_item_fixture(event)
+
+ [seat1, seat2 | _] = plan.sections |> hd() |> Map.get(:seats)
+ {:ok, _} = Seating.assign_seat(seat1.id, event.id, order_item1.id)
+ {:ok, _} = Seating.assign_seat(seat2.id, event.id, order_item2.id)
+
+ assert {:error, :already_reserved} =
+ Seating.reassign_seat(seat1.id, seat2.id, event.id, order_item1.id)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # release_expired_holds/0
+ # ---------------------------------------------------------------------------
+
+ describe "release_expired_holds/0" do
+ test "releases holds that have passed their held_until timestamp" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, reservation} = Seating.hold_seat(seat.id, event.id, cart.id)
+
+ # Manually backdate the held_until to simulate expiry
+ expired_at = DateTime.add(DateTime.utc_now(), -60, :second) |> DateTime.truncate(:second)
+
+ reservation
+ |> Ecto.Changeset.change(held_until: expired_at)
+ |> Pretex.Repo.update!()
+
+ {count, _} = Seating.release_expired_holds()
+ assert count >= 1
+
+ updated = Pretex.Repo.get!(SeatReservation, reservation.id)
+ assert updated.status == "released"
+ end
+
+ test "does not release holds that are still within the time window" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ cart = cart_session_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, reservation} = Seating.hold_seat(seat.id, event.id, cart.id)
+
+ Seating.release_expired_holds()
+
+ updated = Pretex.Repo.get!(SeatReservation, reservation.id)
+ assert updated.status == "held"
+ end
+
+ test "does not release confirmed reservations" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+ order_item = order_item_fixture(event)
+
+ seat = plan.sections |> hd() |> Map.get(:seats) |> hd()
+ {:ok, reservation} = Seating.assign_seat(seat.id, event.id, order_item.id)
+
+ Seating.release_expired_holds()
+
+ updated = Pretex.Repo.get!(SeatReservation, reservation.id)
+ assert updated.status == "confirmed"
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Concurrency: simultaneous seat holds
+ # ---------------------------------------------------------------------------
+
+ describe "concurrent seat holds" do
+ test "only one hold succeeds when two processes race for the same seat" do
+ org = org_fixture()
+ event = event_fixture(org)
+ plan = seating_plan_fixture(org.id)
+
+ [seat | _] = plan.sections |> hd() |> Map.get(:seats)
+
+ cart1 = cart_session_fixture(event)
+ cart2 = cart_session_fixture(event)
+
+ # Race both holds from separate tasks
+ results =
+ [
+ Task.async(fn -> Seating.hold_seat(seat.id, event.id, cart1.id) end),
+ Task.async(fn -> Seating.hold_seat(seat.id, event.id, cart2.id) end)
+ ]
+ |> Task.await_many(5000)
+
+ successes = Enum.count(results, &match?({:ok, _}, &1))
+ failures = Enum.count(results, &match?({:error, :already_reserved}, &1))
+
+ assert successes == 1
+ assert failures == 1
+ end
+ end
+end
diff --git a/pretex/test/pretex_web/live/admin/seating_live_test.exs b/pretex/test/pretex_web/live/admin/seating_live_test.exs
new file mode 100644
index 0000000..694b05b
--- /dev/null
+++ b/pretex/test/pretex_web/live/admin/seating_live_test.exs
@@ -0,0 +1,304 @@
+defmodule PretexWeb.Admin.SeatingLiveTest do
+ use PretexWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Pretex.Events
+ alias Pretex.Organizations
+ alias Pretex.Seating
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ defp org_fixture(attrs \\ %{}) do
+ {:ok, org} =
+ attrs
+ |> Enum.into(%{name: "Test Org", slug: "test-org-#{System.unique_integer([:positive])}"})
+ |> Organizations.create_organization()
+
+ org
+ end
+
+ defp event_fixture(org, attrs) do
+ base = %{
+ name: "Test Event #{System.unique_integer([:positive])}",
+ starts_at: ~U[2030-06-01 10:00:00Z],
+ ends_at: ~U[2030-06-01 18:00:00Z],
+ venue: "Main Stage"
+ }
+
+ {:ok, event} = Events.create_event(org, Enum.into(attrs, base))
+ event
+ end
+
+ defp seating_plan_fixture(org_id, attrs \\ %{}) do
+ layout = %{
+ "sections" => [
+ %{
+ "name" => "Pista",
+ "rows" => [%{"label" => "A", "seats" => 3}]
+ }
+ ]
+ }
+
+ attrs =
+ Enum.into(attrs, %{
+ name: "Test Plan #{System.unique_integer([:positive])}",
+ layout: layout
+ })
+
+ {:ok, plan} = Seating.create_seating_plan(org_id, attrs)
+ plan
+ end
+
+ # ---------------------------------------------------------------------------
+ # Index — list plans
+ # ---------------------------------------------------------------------------
+
+ describe "Index — list plans" do
+ setup :register_and_log_in_user
+
+ test "renders the seating index page", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating")
+
+ assert html =~ "Plantas de Assentos"
+ assert html =~ org.name
+ end
+
+ test "shows empty state when no plans exist", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating")
+
+ assert html =~ "Nenhuma planta ainda."
+ end
+
+ test "lists existing seating plans", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id, %{name: "Teatro Principal"})
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating")
+
+ assert html =~ plan.name
+ end
+
+ test "shows link to create new plan", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating")
+
+ assert html =~ "Nova Planta"
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Index — new plan upload
+ # ---------------------------------------------------------------------------
+
+ describe "Index — new plan form" do
+ setup :register_and_log_in_user
+
+ test "renders the upload form when navigating to :new", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/new")
+
+ assert html =~ "Carregar Nova Planta"
+ assert html =~ "Nome da planta"
+ end
+
+ test "shows validation error for missing plan name", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/new")
+
+ html =
+ view
+ |> form("form", %{"seating_plan" => %{"name" => ""}})
+ |> render_change()
+
+ assert html =~ "can't be blank"
+ end
+
+ test "shows error when no file is uploaded", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/new")
+
+ html =
+ view
+ |> form("form", %{"seating_plan" => %{"name" => "My Plan"}})
+ |> render_submit()
+
+ assert html =~ "Selecione um arquivo JSON"
+ end
+
+ test "cancel navigates back to index", %{conn: conn} do
+ org = org_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/new")
+
+ view |> element("button", "Cancelar") |> render_click()
+
+ assert_patch(view, ~p"/admin/organizations/#{org}/seating")
+ end
+
+ test "deletes a plan from the list", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id, %{name: "Planta Removível"})
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating")
+
+ assert render(view) =~ plan.name
+
+ view
+ |> element("button[phx-click='delete'][phx-value-id='#{plan.id}']")
+ |> render_click()
+
+ refute render(view) =~ plan.name
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Show — plan details
+ # ---------------------------------------------------------------------------
+
+ describe "Show — plan details" do
+ setup :register_and_log_in_user
+
+ test "renders plan details with sections and seats", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id, %{name: "Teatro Central"})
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ assert html =~ "Teatro Central"
+ assert html =~ "Pista"
+ # Seats like A-1
+ assert html =~ "A-1"
+ assert html =~ "A-3"
+ end
+
+ test "shows section capacity and row count", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ assert html =~ "3 assentos"
+ end
+
+ test "shows back navigation link", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ assert html =~ "Voltar às Plantas"
+ end
+
+ test "shows assign to event button", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ assert html =~ "Atribuir a Evento"
+ end
+
+ test "shows map section button for each section", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ assert html =~ "Mapear"
+ end
+
+ test "clicking map section opens inline mapping form", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+ section = hd(plan.sections)
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ html =
+ view
+ |> element("button[phx-click='map_section'][phx-value-section_id='#{section.id}']")
+ |> render_click()
+
+ assert html =~ "Mapear seção a um tipo de ingresso"
+ end
+
+ test "cancel mapping hides the inline form", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+ section = hd(plan.sections)
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ view
+ |> element("button[phx-click='map_section'][phx-value-section_id='#{section.id}']")
+ |> render_click()
+
+ html = view |> element("button", "Cancelar") |> render_click()
+
+ refute html =~ "Mapear seção a um tipo de ingresso"
+ end
+
+ test "clicking assign to event shows event selection form", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+ _event = event_fixture(org, %{name: "Meu Evento"})
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ html =
+ view
+ |> element("button[phx-click='show_assign_event']")
+ |> render_click()
+
+ assert html =~ "Atribuir Planta a Evento"
+ assert html =~ "Meu Evento"
+ end
+
+ test "assigning to an event with no selection shows flash error", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ view |> element("button[phx-click='show_assign_event']") |> render_click()
+
+ html =
+ view
+ |> form("form", %{"assignment" => %{"event_id" => ""}})
+ |> render_submit()
+
+ assert html =~ "Selecione um evento"
+ end
+
+ test "successfully assigns plan to an event", %{conn: conn} do
+ org = org_fixture()
+ plan = seating_plan_fixture(org.id)
+ event = event_fixture(org, %{name: "Festival de Verão"})
+
+ {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}")
+
+ view |> element("button[phx-click='show_assign_event']") |> render_click()
+
+ view
+ |> form("form", %{"assignment" => %{"event_id" => to_string(event.id)}})
+ |> render_submit()
+
+ html = render(view)
+ assert html =~ "Planta atribuída ao evento"
+
+ updated_event = Events.get_event!(event.id)
+ assert updated_event.seating_plan_id == plan.id
+ end
+ end
+end
diff --git a/pretex/test/support/fixtures/seating_fixtures.ex b/pretex/test/support/fixtures/seating_fixtures.ex
new file mode 100644
index 0000000..fc8c4fe
--- /dev/null
+++ b/pretex/test/support/fixtures/seating_fixtures.ex
@@ -0,0 +1,36 @@
+defmodule Pretex.SeatingFixtures do
+ @moduledoc "Fixtures for the Seating context tests."
+
+ alias Pretex.Seating
+
+ @valid_layout %{
+ "sections" => [
+ %{
+ "name" => "Orchestra",
+ "rows" => [
+ %{"label" => "A", "seats" => 5},
+ %{"label" => "B", "seats" => 5}
+ ]
+ },
+ %{
+ "name" => "Balcony",
+ "rows" => [
+ %{"label" => "A", "seats" => 3}
+ ]
+ }
+ ]
+ }
+
+ def valid_layout, do: @valid_layout
+
+ def seating_plan_fixture(org_id, attrs \\ %{}) do
+ attrs =
+ Enum.into(attrs, %{
+ name: "Test Plan #{System.unique_integer([:positive])}",
+ layout: @valid_layout
+ })
+
+ {:ok, plan} = Seating.create_seating_plan(org_id, attrs)
+ plan
+ end
+end