feat: add pagination to adming booking management site

This commit is contained in:
JasterV 2026-02-03 19:28:43 +01:00
parent 3239ef0bd9
commit ecdfc67d26
12 changed files with 1665 additions and 546 deletions

View file

@ -26,8 +26,12 @@ defmodule SpazioSolazzo.BookingSystem do
end
resource SpazioSolazzo.BookingSystem.Booking do
define :admin_search_bookings,
action: :admin_dashboard_search,
define :read_pending_bookings,
action: :read_pending_bookings,
args: [:space_id, :email, :date]
define :read_booking_history,
action: :read_booking_history,
args: [:space_id, :email, :date]
define :search_bookings,

View file

@ -97,38 +97,55 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
end
read :admin_dashboard_search do
description "Search query tailored for the admin booking management panel"
read :read_pending_bookings do
description "Fetch pending bookings for admin dashboard with pagination"
argument :space_id, :uuid, allow_nil?: true
argument :email, :string, allow_nil?: true
argument :date, :date, allow_nil?: true
filter expr(state == :requested or state == :accepted)
# Only requested bookings
filter expr(state == :requested)
pagination do
required? false
offset? true
countable true
default_limit 10
max_page_size 50
end
# Apply shared admin filters preparation
prepare SpazioSolazzo.BookingSystem.Preparations.ApplyAdminFilters
prepare fn query, _ctx ->
query =
case Ash.Query.get_argument(query, :space_id) do
nil -> query
space_id -> Ash.Query.filter(query, space_id == ^space_id)
end
Ash.Query.sort(query, inserted_at: :desc)
end
end
query =
case Ash.Query.get_argument(query, :email) do
nil -> query
email -> Ash.Query.filter(query, customer_email == ^email)
end
read :read_booking_history do
description "Fetch historical bookings (accepted/rejected/cancelled) with pagination"
case Ash.Query.get_argument(query, :date) do
nil ->
query
argument :space_id, :uuid, allow_nil?: true
argument :email, :string, allow_nil?: true
argument :date, :date, allow_nil?: true
date ->
day_start = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
day_end = DateTime.new!(date, ~T[23:59:59], "Etc/UTC")
# Non-pending states
filter expr(state in [:accepted, :rejected, :cancelled])
Ash.Query.filter(query, start_datetime < ^day_end and end_datetime > ^day_start)
end
pagination do
required? false
offset? true
countable true
default_limit 25
max_page_size 100
end
# Apply shared admin filters preparation
prepare SpazioSolazzo.BookingSystem.Preparations.ApplyAdminFilters
prepare fn query, _ctx ->
Ash.Query.sort(query, start_datetime: :desc)
end
end

View file

@ -0,0 +1,40 @@
defmodule SpazioSolazzo.BookingSystem.Preparations.ApplyAdminFilters do
@moduledoc """
Ash Preparation that applies common admin filters (space_id, email, date) to booking queries.
"""
use Ash.Resource.Preparation
@impl true
def prepare(query, _opts, _context) do
query
|> apply_space_filter()
|> apply_email_filter()
|> apply_date_filter()
end
defp apply_space_filter(query) do
case Ash.Query.get_argument(query, :space_id) do
nil -> query
space_id -> Ash.Query.filter(query, space_id == ^space_id)
end
end
defp apply_email_filter(query) do
case Ash.Query.get_argument(query, :email) do
nil -> query
email -> Ash.Query.filter(query, customer_email == ^email)
end
end
defp apply_date_filter(query) do
case Ash.Query.get_argument(query, :date) do
nil ->
query
date ->
day_start = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
day_end = DateTime.new!(date, ~T[23:59:59], "Etc/UTC")
Ash.Query.filter(query, start_datetime < ^day_end and end_datetime > ^day_start)
end
end
end

View file

@ -0,0 +1,233 @@
defmodule SpazioSolazzoWeb.Admin.BookingManagementComponents do
@moduledoc """
Reusable components for the admin booking management interface.
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents
attr :title, :string, required: true
attr :bookings, :list, required: true
attr :page, :map, required: true
attr :current_page, :integer, required: true
attr :event_prefix, :string, required: true
attr :expanded_booking_ids, :any, required: true
attr :show_actions, :boolean, default: false
attr :show_cancellation_details, :boolean, default: false
def bookings_table(assigns) do
~H"""
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-4">{@title}</h2>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead class="bg-slate-50 dark:bg-slate-900">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider w-[4%]">
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Space
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Start
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
End
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Customer
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Status
</th>
<%= if @show_actions do %>
<th class="px-6 py-3 text-center text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider min-w-[240px]">
Actions
</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<%= for booking <- @bookings do %>
<% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %>
<tr class={["group", if(is_expanded, do: "expanded", else: "")]}>
<td class="px-3 py-4 whitespace-nowrap align-top">
<button
phx-click="toggle_expand"
phx-value-booking_id={booking.id}
class="flex items-center justify-center size-7 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<.icon
name="hero-chevron-down"
class={[
"w-4 h-4 transition-transform",
if(is_expanded, do: "rotate-180", else: "")
]}
/>
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-3">
<div class="size-8 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
<.icon name="hero-building-office" class="w-4 h-4" />
</div>
<div>
<p class="font-medium text-slate-900 dark:text-white">
{booking.space.name}
</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_start(booking.start_datetime)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_end(
booking.start_datetime,
booking.end_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<p class="font-medium text-slate-900 dark:text-white">
{booking.customer_name}
</p>
<p class="text-xs text-slate-600 dark:text-slate-400">
{booking.customer_email}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={[
status_badge_classes(booking.state),
"text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1 w-fit"
]}>
<.icon name={status_icon(booking.state)} class="w-3.5 h-3.5" />
{status_label(booking.state)}
</span>
</td>
<%= if @show_actions do %>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex justify-center gap-3">
<button
phx-click="show_reject_modal"
phx-value-booking_id={booking.id}
class="flex items-center justify-center px-4 py-2 rounded-lg border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 bg-white dark:bg-transparent hover:bg-red-50 dark:hover:bg-red-900/20 font-bold text-sm transition-colors"
>
Reject
</button>
<button
phx-click="approve_booking"
phx-value-booking_id={booking.id}
class="flex items-center justify-center px-4 py-2 rounded-lg bg-primary hover:bg-primary-hover text-white font-bold text-sm transition-colors shadow-sm"
>
Confirm
</button>
</div>
</td>
<% end %>
</tr>
<%= if is_expanded do %>
<tr class="bg-slate-50 dark:bg-slate-900/50">
<td class="px-3 py-2"></td>
<td
class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400"
colspan={if @show_actions, do: "6", else: "5"}
>
<div class="flex flex-col gap-2">
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Phone:
</strong>
<%= if booking.customer_phone do %>
{booking.customer_phone}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Note:
</strong>
<%= if booking.customer_comment do %>
{booking.customer_comment}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<%= if @show_cancellation_details && booking.state == :rejected do %>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Rejection Reason:
</strong>
<%= if booking.rejection_reason do %>
{booking.rejection_reason}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<% end %>
<%= if @show_cancellation_details && booking.state == :cancelled do %>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Cancellation Reason:
</strong>
<%= if booking.cancellation_reason do %>
{booking.cancellation_reason}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<% end %>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
<.pagination_controls
page={@page}
current_page={@current_page}
event_prefix={@event_prefix}
/>
</div>
</div>
"""
end
defp status_badge_classes(:requested) do
"bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200"
end
defp status_badge_classes(:accepted) do
"bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200"
end
defp status_badge_classes(:rejected) do
"bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200"
end
defp status_badge_classes(:cancelled) do
"bg-slate-100 dark:bg-slate-900/40 text-slate-800 dark:text-slate-200"
end
defp status_badge_classes(_), do: "bg-slate-100 text-slate-800"
defp status_icon(:requested), do: "hero-clock"
defp status_icon(:accepted), do: "hero-check-circle"
defp status_icon(:rejected), do: "hero-x-circle"
defp status_icon(:cancelled), do: "hero-minus-circle"
defp status_icon(_), do: "hero-question-mark-circle"
defp status_label(:requested), do: "Pending"
defp status_label(:accepted), do: "Confirmed"
defp status_label(:rejected), do: "Rejected"
defp status_label(:cancelled), do: "Cancelled"
defp status_label(_), do: "Unknown"
end

View file

@ -671,6 +671,123 @@ defmodule SpazioSolazzoWeb.CoreComponents do
end
end
@doc """
Renders pagination controls for Ash offset pagination.
## Examples
<.pagination_controls
page={@page}
current_page={@current_page_number}
event_prefix="bookings"
/>
"""
attr :page, :map, required: true, doc: "The Ash.Page.Offset struct"
attr :current_page, :integer, required: true, doc: "Current page number (1-indexed)"
attr :event_prefix, :string, required: true, doc: "Prefix for phx-click event"
def pagination_controls(assigns) do
total_count = assigns.page.count || 0
limit = assigns.page.limit || 10
current = assigns.current_page
start_item = if total_count == 0, do: 0, else: (current - 1) * limit + 1
end_item = min(current * limit, total_count)
total_pages = if limit > 0, do: ceil(total_count / limit), else: 1
# Calculate visible pages (show max 7 with ellipsis)
visible_pages =
cond do
total_pages <= 7 ->
Enum.to_list(1..total_pages)
current <= 4 ->
[1, 2, 3, 4, 5, :ellipsis, total_pages]
current >= total_pages - 3 ->
[1, :ellipsis | Enum.to_list((total_pages - 4)..total_pages)]
true ->
[1, :ellipsis, current - 1, current, current + 1, :ellipsis, total_pages]
end
assigns =
assign(assigns,
total_count: total_count,
start_item: start_item,
end_item: end_item,
total_pages: total_pages,
visible_pages: visible_pages
)
~H"""
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700">
<div class="flex items-center justify-between">
<div class="text-sm text-slate-600 dark:text-slate-400">
Showing {@start_item}-{@end_item} of {@total_count}
</div>
<%= if @total_pages > 1 do %>
<div class="flex items-center gap-2">
<button
phx-click={"#{@event_prefix}_page_change"}
phx-value-page={@current_page - 1}
disabled={@current_page == 1}
class={[
"px-3 py-1.5 rounded-lg font-medium text-sm transition-colors",
if(@current_page == 1,
do: "bg-slate-200 dark:bg-slate-700 text-slate-400 cursor-not-allowed",
else:
"bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-300 dark:border-slate-600 hover:bg-slate-50"
)
]}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<%= for page_num <- @visible_pages do %>
<%= if page_num == :ellipsis do %>
<span class="px-2 text-slate-400">...</span>
<% else %>
<button
phx-click={"#{@event_prefix}_page_change"}
phx-value-page={page_num}
class={[
"px-3 py-1.5 rounded-lg font-medium text-sm transition-colors",
if(page_num == @current_page,
do: "bg-primary text-white",
else:
"bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-300 dark:border-slate-600 hover:bg-slate-50"
)
]}
>
{page_num}
</button>
<% end %>
<% end %>
<button
phx-click={"#{@event_prefix}_page_change"}
phx-value-page={@current_page + 1}
disabled={@current_page == @total_pages}
class={[
"px-3 py-1.5 rounded-lg font-medium text-sm transition-colors",
if(@current_page == @total_pages,
do: "bg-slate-200 dark:bg-slate-700 text-slate-400 cursor-not-allowed",
else:
"bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-300 dark:border-slate-600 hover:bg-slate-50"
)
]}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
<% end %>
</div>
</div>
"""
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""

View file

@ -5,14 +5,27 @@ defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
use SpazioSolazzoWeb, :live_view
import SpazioSolazzoWeb.Admin.BookingManagementComponents
alias SpazioSolazzo.BookingSystem
@pending_bookings_page_limit 10
@booking_history_page_limit 10
def mount(_params, _session, socket) do
{:ok, spaces} = Ash.read(SpazioSolazzo.BookingSystem.Space)
{:ok, bookings} = BookingSystem.admin_search_bookings(nil, nil, nil, load: [:space, :user])
# Separate pending and other bookings
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
{:ok, pending_page} =
BookingSystem.read_pending_bookings(nil, nil, nil,
page: [limit: @pending_bookings_page_limit, offset: 0, count: true],
load: [:space, :user]
)
{:ok, history_page} =
BookingSystem.read_booking_history(nil, nil, nil,
page: [limit: @booking_history_page_limit, offset: 0, count: true],
load: [:space, :user]
)
if connected?(socket) do
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
@ -24,8 +37,10 @@ defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
{:ok,
assign(socket,
spaces: spaces,
pending_bookings: pending,
past_bookings: past,
pending_page: pending_page,
history_page: history_page,
pending_page_number: 1,
history_page_number: 1,
filter_space_id: nil,
filter_email: "",
filter_date: nil,
@ -37,26 +52,31 @@ defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
end
def handle_params(params, _uri, socket) do
# Parse date from URL if present
filter_date =
case Map.get(params, "date") do
nil ->
nil
# Parse URL parameters - URL is the source of truth for all filters
filter_space_id = if params["space_id"] == "" || is_nil(params["space_id"]), do: nil, else: params["space_id"]
filter_email = params["email"] || ""
filter_date = parse_date_param(params["date"])
pending_page = parse_page_param(params["pending_page"])
history_page = parse_page_param(params["history_page"])
"" ->
nil
# Determine if we need to reload data
needs_reload =
params_changed?(
socket.assigns.filter_space_id,
filter_space_id,
socket.assigns.filter_email,
filter_email,
socket.assigns.filter_date,
filter_date,
socket.assigns.pending_page_number,
pending_page,
socket.assigns.history_page_number,
history_page
)
date_string ->
case Date.from_iso8601(date_string) do
{:ok, date} -> date
{:error, _} -> nil
end
end
# If we have a date from URL, apply it to filters
socket =
if filter_date && socket.assigns.filter_date != filter_date do
apply_date_filter(socket, filter_date)
if needs_reload do
reload_bookings(socket, filter_space_id, filter_email, filter_date, pending_page, history_page)
else
socket
end
@ -84,33 +104,56 @@ defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
do: nil,
else: Date.from_iso8601!(params["date"])
{:ok, bookings} =
BookingSystem.admin_search_bookings(space_id, email, date, load: [:space, :user])
{:ok, pending_page} =
BookingSystem.read_pending_bookings(space_id, email, date,
page: [limit: @pending_bookings_page_limit, offset: 0, count: true],
load: [:space, :user]
)
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
{:ok, history_page} =
BookingSystem.read_booking_history(space_id, email, date,
page: [limit: @booking_history_page_limit, offset: 0, count: true],
load: [:space, :user]
)
{:noreply,
assign(socket,
pending_bookings: pending,
past_bookings: past,
filter_space_id: space_id,
filter_email: email || "",
filter_date: date
)}
updated_socket =
assign(socket,
pending_page: pending_page,
history_page: history_page,
pending_page_number: 1,
history_page_number: 1,
filter_space_id: space_id,
filter_email: email || "",
filter_date: date
)
{:noreply, push_patch(updated_socket, to: build_path(updated_socket, 1, 1))}
end
def handle_event("clear_filters", _, socket) do
{:ok, bookings} = BookingSystem.admin_search_bookings(nil, nil, nil, load: [:space, :user])
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
{:noreply, push_patch(socket, to: ~p"/admin/bookings")}
end
{:noreply,
assign(socket,
pending_bookings: pending,
past_bookings: past,
filter_space_id: nil,
filter_email: "",
filter_date: nil
)}
def handle_event("pending_page_change", %{"page" => page_str}, socket) do
page_number = String.to_integer(page_str)
socket =
push_patch(socket,
to: build_path(socket, page_number, socket.assigns.history_page_number)
)
{:noreply, socket}
end
def handle_event("history_page_change", %{"page" => page_str}, socket) do
page_number = String.to_integer(page_str)
socket =
push_patch(socket,
to: build_path(socket, socket.assigns.pending_page_number, page_number)
)
{:noreply, socket}
end
def handle_event("approve_booking", %{"booking_id" => booking_id}, socket) do
@ -196,66 +239,124 @@ defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
end
defp refresh_bookings(socket) do
{:ok, bookings} =
BookingSystem.admin_search_bookings(
pending_page_number = socket.assigns.pending_page_number
history_page_number = socket.assigns.history_page_number
pending_offset = (pending_page_number - 1) * @pending_bookings_page_limit
history_offset = (history_page_number - 1) * @booking_history_page_limit
{:ok, pending_page} =
BookingSystem.read_pending_bookings(
socket.assigns.filter_space_id,
socket.assigns.filter_email,
socket.assigns.filter_date,
page: [limit: @pending_bookings_page_limit, offset: pending_offset, count: true],
load: [:space, :user]
)
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
{:ok, history_page} =
BookingSystem.read_booking_history(
socket.assigns.filter_space_id,
socket.assigns.filter_email,
socket.assigns.filter_date,
page: [limit: @booking_history_page_limit, offset: history_offset, count: true],
load: [:space, :user]
)
{:noreply,
assign(socket,
pending_bookings: pending,
past_bookings: past
pending_page: pending_page,
history_page: history_page
)}
end
defp apply_date_filter(socket, date) do
space_id = socket.assigns.filter_space_id
email = if socket.assigns.filter_email == "", do: nil, else: socket.assigns.filter_email
defp reload_bookings(socket, filter_space_id, filter_email, filter_date, pending_page_number, history_page_number) do
pending_offset = (pending_page_number - 1) * @pending_bookings_page_limit
history_offset = (history_page_number - 1) * @booking_history_page_limit
{:ok, bookings} =
BookingSystem.admin_search_bookings(space_id, email, date, load: [:space, :user])
email = if filter_email == "", do: nil, else: filter_email
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
{:ok, pending_page} =
BookingSystem.read_pending_bookings(filter_space_id, email, filter_date,
page: [limit: @pending_bookings_page_limit, offset: pending_offset, count: true],
load: [:space, :user]
)
{:ok, history_page} =
BookingSystem.read_booking_history(filter_space_id, email, filter_date,
page: [limit: @booking_history_page_limit, offset: history_offset, count: true],
load: [:space, :user]
)
assign(socket,
pending_bookings: pending,
past_bookings: past,
filter_date: date
pending_page: pending_page,
history_page: history_page,
pending_page_number: pending_page_number,
history_page_number: history_page_number,
filter_space_id: filter_space_id,
filter_email: filter_email,
filter_date: filter_date
)
end
defp status_badge_classes(:requested) do
"bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200"
defp build_path(socket, pending_page, history_page) do
query_params = []
query_params =
if pending_page != 1,
do: [{"pending_page", pending_page} | query_params],
else: query_params
query_params =
if history_page != 1,
do: [{"history_page", history_page} | query_params],
else: query_params
query_params =
if socket.assigns.filter_space_id,
do: [{"space_id", socket.assigns.filter_space_id} | query_params],
else: query_params
query_params =
if socket.assigns.filter_email && socket.assigns.filter_email != "",
do: [{"email", socket.assigns.filter_email} | query_params],
else: query_params
query_params =
if socket.assigns.filter_date,
do: [{"date", Date.to_iso8601(socket.assigns.filter_date)} | query_params],
else: query_params
base_path = ~p"/admin/bookings"
if query_params == [] do
base_path
else
query_string = URI.encode_query(query_params)
"#{base_path}?#{query_string}"
end
end
defp status_badge_classes(:accepted) do
"bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200"
defp parse_date_param(nil), do: nil
defp parse_date_param(""), do: nil
defp parse_date_param(date_string) do
case Date.from_iso8601(date_string) do
{:ok, date} -> date
{:error, _} -> nil
end
end
defp status_badge_classes(:rejected) do
"bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200"
defp parse_page_param(nil), do: 1
defp parse_page_param(""), do: 1
defp parse_page_param(page_string) do
case Integer.parse(page_string) do
{page, _} when page > 0 -> page
_ -> 1
end
end
defp status_badge_classes(:cancelled) do
"bg-slate-100 dark:bg-slate-900/40 text-slate-800 dark:text-slate-200"
defp params_changed?(old_space_id, new_space_id, old_email, new_email, old_date, new_date, old_pending, new_pending, old_history, new_history) do
old_space_id != new_space_id or old_email != new_email or old_date != new_date or old_pending != new_pending or old_history != new_history
end
defp status_badge_classes(_), do: "bg-slate-100 text-slate-800"
defp status_icon(:requested), do: "hero-clock"
defp status_icon(:accepted), do: "hero-check-circle"
defp status_icon(:rejected), do: "hero-x-circle"
defp status_icon(:cancelled), do: "hero-minus-circle"
defp status_icon(_), do: "hero-question-mark-circle"
defp status_label(:requested), do: "Pending"
defp status_label(:accepted), do: "Confirmed"
defp status_label(:rejected), do: "Rejected"
defp status_label(:cancelled), do: "Cancelled"
defp status_label(_), do: "Unknown"
end

View file

@ -21,7 +21,7 @@
<span class="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400">
Pending
</span>
<span class="text-2xl font-bold text-primary">{length(@pending_bookings)}</span>
<span class="text-2xl font-bold text-primary">{@pending_page.count}</span>
</div>
</div>
</div>
@ -30,6 +30,7 @@
<div class="bg-white dark:bg-slate-800 p-5 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700">
<form
phx-change="filter_bookings"
phx-submit="filter_bookings"
class="grid grid-cols-1 md:grid-cols-12 gap-4 items-end"
>
<div class="col-span-1 md:col-span-4 flex flex-col gap-1.5">
@ -105,325 +106,40 @@
</form>
</div>
<%!-- Pending Bookings Table --%>
<%= if @pending_bookings != [] do %>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-4">Pending Requests</h2>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead class="bg-slate-50 dark:bg-slate-900">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider w-[4%]">
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Space
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Start
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
End
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Customer
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-center text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider min-w-[240px]">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<%= for booking <- @pending_bookings do %>
<% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %>
<tr class={["group", if(is_expanded, do: "expanded", else: "")]}>
<td class="px-3 py-4 whitespace-nowrap align-top">
<button
phx-click="toggle_expand"
phx-value-booking_id={booking.id}
class="flex items-center justify-center size-7 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<.icon
name="hero-chevron-down"
class={[
"w-4 h-4 transition-transform",
if(is_expanded, do: "rotate-180", else: "")
]}
/>
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-3">
<div class="size-8 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
<.icon name="hero-building-office" class="w-4 h-4" />
</div>
<div>
<p class="font-medium text-slate-900 dark:text-white">
{booking.space.name}
</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_start(
booking.start_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_end(
booking.start_datetime,
booking.end_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<p class="font-medium text-slate-900 dark:text-white">
{booking.customer_name}
</p>
<p class="text-xs text-slate-600 dark:text-slate-400">
{booking.customer_email}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={[
status_badge_classes(booking.state),
"text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1 w-fit"
]}>
<.icon name={status_icon(booking.state)} class="w-3.5 h-3.5" />
{status_label(booking.state)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex justify-center gap-3">
<button
phx-click="show_reject_modal"
phx-value-booking_id={booking.id}
class="flex items-center justify-center px-4 py-2 rounded-lg border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 bg-white dark:bg-transparent hover:bg-red-50 dark:hover:bg-red-900/20 font-bold text-sm transition-colors"
>
Reject
</button>
<button
phx-click="approve_booking"
phx-value-booking_id={booking.id}
class="flex items-center justify-center px-4 py-2 rounded-lg bg-primary hover:bg-primary-hover text-white font-bold text-sm transition-colors shadow-sm"
>
Confirm
</button>
</div>
</td>
</tr>
<%= if is_expanded do %>
<tr class="bg-slate-50 dark:bg-slate-900/50">
<td class="px-3 py-2"></td>
<td
class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400"
colspan="6"
>
<div class="flex flex-col gap-2">
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Phone:
</strong>
<%= if booking.customer_phone do %>
{booking.customer_phone}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Note:
</strong>
<%= if booking.customer_comment do %>
{booking.customer_comment}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<%!-- Past Bookings Table --%>
<%= if @past_bookings != [] do %>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-4">Booking History</h2>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead class="bg-slate-50 dark:bg-slate-900">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider w-[4%]">
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Space
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Start
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
End
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Customer
</th>
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<%= for booking <- @past_bookings do %>
<% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %>
<tr class={["group", if(is_expanded, do: "expanded", else: "")]}>
<td class="px-3 py-4 whitespace-nowrap align-top">
<button
phx-click="toggle_expand"
phx-value-booking_id={booking.id}
class="flex items-center justify-center size-7 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<.icon
name="hero-chevron-down"
class={[
"w-4 h-4 transition-transform",
if(is_expanded, do: "rotate-180", else: "")
]}
/>
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-3">
<div class="size-8 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
<.icon name="hero-building-office" class="w-4 h-4" />
</div>
<div>
<p class="font-medium text-slate-900 dark:text-white">
{booking.space.name}
</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_start(
booking.start_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_end(
booking.start_datetime,
booking.end_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<p class="font-medium text-slate-900 dark:text-white">
{booking.customer_name}
</p>
<p class="text-xs text-slate-600 dark:text-slate-400">
{booking.customer_email}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={[
status_badge_classes(booking.state),
"text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1 w-fit"
]}>
<.icon name={status_icon(booking.state)} class="w-3.5 h-3.5" />
{status_label(booking.state)}
</span>
</td>
</tr>
<%= if is_expanded do %>
<tr class="bg-slate-50 dark:bg-slate-900/50">
<td class="px-3 py-2"></td>
<td
class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400"
colspan="5"
>
<div class="flex flex-col gap-2">
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Phone:
</strong>
<%= if booking.customer_phone do %>
{booking.customer_phone}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Note:
</strong>
<%= if booking.customer_comment do %>
{booking.customer_comment}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<%= if booking.state == :rejected do %>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Rejection Reason:
</strong>
<%= if booking.rejection_reason do %>
{booking.rejection_reason}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<% end %>
<%= if booking.state == :cancelled do %>
<p>
<strong class="font-semibold text-slate-900 dark:text-white">
Cancellation Reason:
</strong>
<%= if booking.cancellation_reason do %>
{booking.cancellation_reason}
<% else %>
<span class="italic text-slate-400">Not provided</span>
<% end %>
</p>
<% end %>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<%= if @pending_bookings == [] && @past_bookings == [] do %>
<%= if @pending_page.count == 0 && @history_page.count == 0 do %>
<div class="text-center py-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl">
<.icon name="hero-inbox" class="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p class="text-slate-500 dark:text-slate-400 text-lg">No bookings found</p>
</div>
<% end %>
<%!-- Pending Bookings Table --%>
<%= if @pending_page.count > 0 do %>
<.bookings_table
title="Pending Requests"
bookings={@pending_page.results}
page={@pending_page}
current_page={@pending_page_number}
event_prefix="pending"
expanded_booking_ids={@expanded_booking_ids}
show_actions={true}
show_cancellation_details={false}
/>
<% end %>
<%!-- Past Bookings Table --%>
<%= if @history_page.count > 0 do %>
<.bookings_table
title="Booking History"
bookings={@history_page.results}
page={@history_page}
current_page={@history_page_number}
event_prefix="history"
expanded_booking_ids={@expanded_booking_ids}
show_actions={false}
show_cancellation_details={true}
/>
<% end %>
</div>
</main>

View file

@ -1,6 +1,5 @@
%{
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},

View file

@ -0,0 +1,399 @@
defmodule SpazioSolazzo.BookingSystem.BookingPaginationTest do
use ExUnit.Case, async: true
use SpazioSolazzo.DataCase
alias SpazioSolazzo.BookingSystem
describe "read_pending_bookings/3 pagination" do
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking-pagination-test",
"Test space for pagination",
10
)
base_date = Date.add(Date.utc_today(), 1)
pending_bookings =
for i <- 1..15 do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
base_date,
~T[09:00:00],
~T[10:00:00],
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
booking
end
%{space: space, pending_bookings: pending_bookings, tomorrow: base_date}
end
test "returns first page with default limit of 10" do
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
nil,
nil,
page: [limit: 10, offset: 0, count: true]
)
assert length(page.results) == 10
assert page.count == 15
assert page.limit == 10
assert page.offset == 0
assert page.more? == true
end
test "returns second page correctly" do
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
nil,
nil,
page: [limit: 10, offset: 10, count: true]
)
assert length(page.results) == 5
assert page.count == 15
assert page.more? == false
end
test "filters by space_id", %{space: space} do
{:ok, other_space} =
BookingSystem.create_space(
"Other Space",
"other-space-pagination",
"Another test space",
5
)
tomorrow = Date.add(Date.utc_today(), 1)
{:ok, _} =
BookingSystem.create_booking(
other_space.id,
nil,
tomorrow,
~T[10:00:00],
~T[11:00:00],
"Other Customer",
"other@example.com",
nil,
nil
)
{:ok, page} =
BookingSystem.read_pending_bookings(
space.id,
nil,
nil,
page: [limit: 10, offset: 0, count: true]
)
assert page.count == 15
assert Enum.all?(page.results, fn b -> b.space_id == space.id end)
end
test "filters by email" do
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
"customer1@example.com",
nil,
page: [limit: 10, offset: 0, count: true]
)
assert page.count == 1
assert hd(page.results).customer_email == "customer1@example.com"
end
test "filters by date", %{tomorrow: tomorrow} do
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
nil,
tomorrow,
page: [limit: 20, offset: 0, count: true]
)
assert page.count == 15
assert Enum.all?(page.results, fn b -> DateTime.to_date(b.start_datetime) == tomorrow end)
end
test "sorts by inserted_at descending (newest first)" do
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
nil,
nil,
page: [limit: 2, offset: 0, count: true]
)
[first, second] = page.results
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
end
test "returns empty results when no bookings match" do
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
"nonexistent@example.com",
nil,
page: [limit: 10, offset: 0, count: true]
)
assert page.results == []
assert page.count == 0
assert page.more? == false
end
test "only returns requested state bookings", %{pending_bookings: bookings} do
[first_booking | _] = bookings
{:ok, _} = BookingSystem.approve_booking(first_booking)
{:ok, page} =
BookingSystem.read_pending_bookings(
nil,
nil,
nil,
page: [limit: 20, offset: 0, count: true],
load: [:space]
)
assert page.count == 14
assert Enum.all?(page.results, fn b -> b.state == :requested end)
end
test "combined filters work together", %{space: space, tomorrow: tomorrow} do
{:ok, page} =
BookingSystem.read_pending_bookings(
space.id,
"customer5@example.com",
tomorrow,
page: [limit: 10, offset: 0, count: true]
)
assert page.count == 1
result = hd(page.results)
assert result.space_id == space.id
assert result.customer_email == "customer5@example.com"
assert DateTime.to_date(result.start_datetime) == tomorrow
end
end
describe "read_booking_history/3 pagination" do
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking History",
"coworking-history-test",
"Test space for history pagination",
10
)
base_date = Date.add(Date.utc_today(), 1)
for i <- 1..30 do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
base_date,
~T[09:00:00],
~T[10:00:00],
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
cond do
rem(i, 10) == 0 ->
BookingSystem.reject_booking(booking, "Test rejection")
rem(i, 5) == 0 ->
{:ok, approved} = BookingSystem.approve_booking(booking)
BookingSystem.cancel_booking(approved, "Test cancellation")
true ->
BookingSystem.approve_booking(booking)
end
end
%{space: space, tomorrow: base_date}
end
test "returns first page with default limit of 25" do
{:ok, page} =
BookingSystem.read_booking_history(
nil,
nil,
nil,
page: [limit: 25, offset: 0, count: true]
)
assert length(page.results) == 25
assert page.count == 30
assert page.more? == true
end
test "returns second page correctly" do
{:ok, page} =
BookingSystem.read_booking_history(
nil,
nil,
nil,
page: [limit: 25, offset: 25, count: true]
)
assert length(page.results) == 5
assert page.count == 30
assert page.more? == false
end
test "returns only accepted, rejected, and cancelled bookings", %{space: space} do
tomorrow = Date.add(Date.utc_today(), 2)
{:ok, _pending} =
BookingSystem.create_booking(
space.id,
nil,
tomorrow,
~T[10:00:00],
~T[11:00:00],
"Pending Customer",
"pending@example.com",
nil,
nil
)
{:ok, page} =
BookingSystem.read_booking_history(
nil,
nil,
nil,
page: [limit: 50, offset: 0, count: true]
)
assert page.count == 30
assert Enum.all?(page.results, fn b -> b.state in [:accepted, :rejected, :cancelled] end)
end
test "sorts by start_datetime descending (most recent first)" do
{:ok, page} =
BookingSystem.read_booking_history(
nil,
nil,
nil,
page: [limit: 2, offset: 0, count: true]
)
[first, second] = page.results
assert DateTime.compare(first.start_datetime, second.start_datetime) in [:gt, :eq]
end
test "filters by space_id", %{space: space} do
{:ok, other_space} =
BookingSystem.create_space(
"Other History Space",
"other-history-space",
"Another test space",
5
)
tomorrow = Date.add(Date.utc_today(), 1)
{:ok, other_booking} =
BookingSystem.create_booking(
other_space.id,
nil,
tomorrow,
~T[10:00:00],
~T[11:00:00],
"Other Customer",
"other@example.com",
nil,
nil
)
BookingSystem.approve_booking(other_booking)
{:ok, page} =
BookingSystem.read_booking_history(
space.id,
nil,
nil,
page: [limit: 50, offset: 0, count: true]
)
assert page.count == 30
assert Enum.all?(page.results, fn b -> b.space_id == space.id end)
end
test "filters by email" do
{:ok, page} =
BookingSystem.read_booking_history(
nil,
"customer1@example.com",
nil,
page: [limit: 10, offset: 0, count: true]
)
assert page.count == 1
assert hd(page.results).customer_email == "customer1@example.com"
end
test "filters by date", %{tomorrow: tomorrow} do
{:ok, page} =
BookingSystem.read_booking_history(
nil,
nil,
tomorrow,
page: [limit: 50, offset: 0, count: true]
)
assert page.count == 30
assert Enum.all?(page.results, fn b -> DateTime.to_date(b.start_datetime) == tomorrow end)
end
test "combined filters work together", %{space: space, tomorrow: tomorrow} do
{:ok, page} =
BookingSystem.read_booking_history(
space.id,
"customer2@example.com",
tomorrow,
page: [limit: 10, offset: 0, count: true]
)
assert page.count == 1
result = hd(page.results)
assert result.space_id == space.id
assert result.customer_email == "customer2@example.com"
assert DateTime.to_date(result.start_datetime) == tomorrow
end
test "returns empty results when no bookings match" do
{:ok, page} =
BookingSystem.read_booking_history(
nil,
"nonexistent@example.com",
nil,
page: [limit: 25, offset: 0, count: true]
)
assert page.results == []
assert page.count == 0
assert page.more? == false
end
end
end

View file

@ -493,129 +493,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
end
end
describe "admin_search_bookings/3" do
test "returns pending and approved bookings for space", %{space: space, date: date} do
{:ok, pending} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, approved} =
request_booking(
space.id,
nil,
date,
~T[10:00:00],
~T[11:00:00],
"User 2",
"user2@example.com",
"",
""
)
{:ok, _} = BookingSystem.approve_booking(approved.id)
{:ok, cancelled} =
request_booking(
space.id,
nil,
date,
~T[11:00:00],
~T[12:00:00],
"User 3",
"user3@example.com",
"",
""
)
{:ok, _} = BookingSystem.cancel_booking(cancelled.id, "Test cancellation")
{:ok, bookings} = BookingSystem.admin_search_bookings(space.id, nil, nil)
assert length(bookings) == 2
assert Enum.any?(bookings, &(&1.id == pending.id))
assert Enum.any?(bookings, &(&1.id == approved.id))
refute Enum.any?(bookings, &(&1.id == cancelled.id))
end
test "filters by email", %{space: space, date: date} do
{:ok, _booking1} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, booking2} =
request_booking(
space.id,
nil,
date,
~T[10:00:00],
~T[11:00:00],
"User 2",
"user2@example.com",
"",
""
)
{:ok, bookings} =
BookingSystem.admin_search_bookings(space.id, "user2@example.com", nil)
assert length(bookings) == 1
assert hd(bookings).id == booking2.id
end
test "filters by date", %{space: space, date: date} do
other_date = Date.add(date, 1)
{:ok, booking1} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, _booking2} =
request_booking(
space.id,
nil,
other_date,
~T[09:00:00],
~T[10:00:00],
"User 2",
"user2@example.com",
"",
""
)
{:ok, bookings} = BookingSystem.admin_search_bookings(space.id, nil, date)
assert length(bookings) == 1
assert hd(bookings).id == booking1.id
end
end
describe "count pending requests" do
test "returns only pending bookings", %{space: space, date: date} do
{:ok, _pending1} =

View file

@ -0,0 +1,582 @@
defmodule SpazioSolazzoWeb.Admin.BookingManagementPaginationTest do
use SpazioSolazzoWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import SpazioSolazzo.AuthHelpers
import Ecto.Query
alias SpazioSolazzo.BookingSystem
defp create_admin_user do
user = register_user("admin@example.com", "Admin User")
{:ok, uuid} = Ecto.UUID.dump(user.id)
from(u in "users", where: u.id == ^uuid)
|> SpazioSolazzo.Repo.update_all(set: [role: "admin"])
user
end
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking-pagination",
"Coworking space",
10
)
admin_user = create_admin_user()
tomorrow = Date.add(Date.utc_today(), 1)
%{space: space, admin_user: admin_user, tomorrow: tomorrow}
end
describe "pagination - pending bookings" do
test "displays first page of pending bookings", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, html} = live(conn, "/admin/bookings")
assert html =~ "Showing 1-10 of 15"
assert has_element?(view, "button[phx-click='pending_page_change']")
end
test "navigates to second page of pending bookings", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
html =
view
|> element("button[phx-click='pending_page_change']", "2")
|> render_click()
assert html =~ "Showing 11-15 of 15"
end
test "pagination URL params are updated", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='pending_page_change']", "2")
|> render_click()
assert_patch(view, "/admin/bookings?pending_page=2")
end
test "filters reset pagination to page 1", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings?pending_page=2")
view
|> form("form", %{"email" => "customer1@example.com"})
|> render_change()
path = assert_patch(view)
refute path =~ "pending_page=2"
end
test "previous button is disabled on first page", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, html} = live(conn, "/admin/bookings")
assert html =~ "disabled"
assert has_element?(view, "button[phx-click='pending_page_change'][disabled]")
end
end
describe "pagination - booking history" do
test "displays first page of booking history", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
for i <- 1..30 do
start_datetime = DateTime.new!(tomorrow, Time.add(~T[09:00:00], i * 3600), "Etc/UTC")
end_datetime = DateTime.add(start_datetime, 3600, :second)
{:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Customer #{i}",
"customer#{i}@example.com",
nil
)
BookingSystem.approve_booking(booking)
end
conn = log_in_user(conn, admin_user)
{:ok, view, html} = live(conn, "/admin/bookings")
assert html =~ "Showing 1-10 of 30"
assert has_element?(view, "button[phx-click='history_page_change'][phx-value-page='2']")
end
test "navigates to second page of booking history", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
for i <- 1..30 do
start_datetime = DateTime.new!(tomorrow, Time.add(~T[09:00:00], i * 3600), "Etc/UTC")
end_datetime = DateTime.add(start_datetime, 3600, :second)
{:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Customer #{i}",
"customer#{i}@example.com",
nil
)
BookingSystem.approve_booking(booking)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
html =
view
|> element("button[phx-click='history_page_change']", "2")
|> render_click()
assert html =~ "Showing 11-20 of 30"
end
test "history pagination URL params are updated", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
for i <- 1..30 do
start_datetime = DateTime.new!(tomorrow, Time.add(~T[09:00:00], i * 3600), "Etc/UTC")
end_datetime = DateTime.add(start_datetime, 3600, :second)
{:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Customer #{i}",
"customer#{i}@example.com",
nil
)
BookingSystem.approve_booking(booking)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='history_page_change']", "2")
|> render_click()
assert_patch(view, "/admin/bookings?history_page=2")
end
end
describe "pagination with booking management" do
test "approving booking refreshes current page", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
{:ok, booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
~T[10:00:00],
~T[11:00:00],
"Test Customer",
"test@example.com",
nil,
nil
)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
# Trigger a refresh
view |> form("form", %{"email" => ""}) |> render_change()
view
|> element("button[phx-click='approve_booking'][phx-value-booking_id='#{booking.id}']")
|> render_click()
# Just verify the view still works after approval
html = render(view)
assert html =~ "Manage Bookings"
end
test "rejecting booking refreshes current page", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
{:ok, booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
~T[14:00:00],
~T[15:00:00],
"Test Customer",
"test2@example.com",
nil,
nil
)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
# Trigger a refresh
view |> form("form", %{"email" => ""}) |> render_change()
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
view
|> element("textarea[name='reason']")
|> render_change(%{"reason" => "Test rejection"})
view
|> element("form[phx-submit='confirm_reject']")
|> render_submit()
# Just verify the view still works after rejection
html = render(view)
assert html =~ "Manage Bookings"
end
end
describe "empty states with pagination" do
test "shows empty state when no bookings exist", %{
conn: conn,
admin_user: admin_user
} do
conn = log_in_user(conn, admin_user)
{:ok, _view, html} = live(conn, "/admin/bookings")
assert html =~ "No bookings found"
end
test "shows pending count as 0 when no pending bookings", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
start_datetime = DateTime.new!(tomorrow, ~T[10:00:00], "Etc/UTC")
end_datetime = DateTime.add(start_datetime, 3600, :second)
{:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Customer",
"customer@example.com",
nil
)
BookingSystem.approve_booking(booking)
conn = log_in_user(conn, admin_user)
{:ok, _view, html} = live(conn, "/admin/bookings")
assert html =~ "<span class=\"text-2xl font-bold text-primary\">0</span>"
end
end
describe "pagination with filters" do
test "pagination works with space filter", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> form("form", %{"space_id" => space.id})
|> render_change()
html = render(view)
assert html =~ "Showing 1-10 of 15"
end
test "pagination works with email filter", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> form("form", %{"email" => "customer1@example.com"})
|> render_change()
html = render(view)
assert html =~ "Showing 1-1 of 1"
end
test "pagination works with date filter", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> form("form", %{"date" => Date.to_iso8601(tomorrow)})
|> render_change()
html = render(view)
assert html =~ "Showing 1-10 of 15"
end
test "clear filters resets pagination", %{
conn: conn,
admin_user: admin_user,
space: space,
tomorrow: tomorrow
} do
user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
for i <- 1..15 do
hour = rem(8 + i, 24)
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour, 30, 0)
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Customer #{i}",
"customer#{i}@example.com",
nil,
nil
)
end
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings?pending_page=2")
view
|> element("button[phx-click='clear_filters']")
|> render_click()
path = assert_patch(view)
refute path =~ "pending_page=2"
end
end
end

View file

@ -164,7 +164,11 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
describe "SpaceBooking date selection" do
test "updates time slots when selecting a different date", %{conn: conn, space: space} do
monday_date = ~D[2026-02-02]
# Find next Monday from today
today = Date.utc_today()
days_until_monday = rem(8 - Date.day_of_week(today), 7)
days_until_monday = if days_until_monday == 0, do: 7, else: days_until_monday
monday_date = Date.add(today, days_until_monday)
{:ok, _monday_slot} =
BookingSystem.create_time_slot_template(
@ -394,7 +398,17 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
# Process the handle_info message that creates the booking
render(view)
{:ok, bookings} = BookingSystem.admin_search_bookings(space.id, nil, today)
day_start = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
day_end = DateTime.new!(today, ~T[23:59:59], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
day_start,
day_end,
[:requested],
[:customer_name, :customer_email, :customer_phone, :customer_comment, :state]
)
assert length(bookings) == 1
booking = hd(bookings)
@ -578,7 +592,17 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
# Process the handle_info message that creates the booking
render(view)
{:ok, bookings} = BookingSystem.admin_search_bookings(space.id, nil, today)
day_start = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
day_end = DateTime.new!(today, ~T[23:59:59], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
day_start,
day_end,
[:requested],
[:customer_email, :user_id]
)
assert length(bookings) == 1
booking = hd(bookings)
@ -632,7 +656,14 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
test "handles rapid date changes", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
dates = [~D[2026-02-02], ~D[2026-02-03], ~D[2026-02-04], ~D[2026-02-02]]
# Use future dates relative to today
today = Date.utc_today()
dates = [
Date.add(today, 1),
Date.add(today, 2),
Date.add(today, 3),
Date.add(today, 1)
]
for date <- dates do
view
@ -641,7 +672,10 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
end
html = render(view)
assert html =~ "Monday, February 02, 2026"
# Verify the last selected date is shown (which is Date.add(today, 1))
final_date = Date.add(today, 1)
formatted_date = Calendar.strftime(final_date, "%A, %B %d, %Y")
assert html =~ formatted_date
end
end
end