feat: new booking system + admin dashboard (#12)

feat: implement a new booking system and admin dashboard
This commit is contained in:
Víctor Martínez 2026-02-07 19:08:39 +01:00 committed by GitHub
parent bbc2f08215
commit 69f992f8f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 7067 additions and 2121 deletions

View file

@ -1,7 +1,6 @@
[
import_deps: [
:oban,
:ash_admin,
:ash_authentication_phoenix,
:ash_authentication,
:ash_postgres,

View file

@ -20,6 +20,7 @@ defmodule SpazioSolazzo.Accounts do
define :get_user_by_email, action: :read, get_by: [:email]
define :terminate_account, action: :terminate_account, args: [:delete_history]
define :update_profile, action: :update_profile, args: [:name, :phone_number]
define :make_admin, action: :make_admin, args: []
end
end
end

View file

@ -104,6 +104,11 @@ defmodule SpazioSolazzo.Accounts.User do
require_atomic? false
end
update :make_admin do
accept []
change set_attribute(:role, :admin)
end
destroy :terminate_account do
description "Delete user account with optional booking data removal"
require_atomic? false
@ -122,6 +127,10 @@ defmodule SpazioSolazzo.Accounts.User do
authorize_if always()
end
policy action(:make_admin) do
authorize_if never()
end
policy action_type(:read) do
authorize_if always()
end

View file

@ -2,7 +2,7 @@ defmodule SpazioSolazzo.Accounts.User.Changes.HandleBookingsOnAccountDeletion do
@moduledoc """
Handles booking cleanup when a user account is terminated.
- Cancels all future reserved bookings
- Cancels all future requested/accepted bookings with a reason
- Either deletes all bookings or lets the database nullify them based on delete_history argument
"""
use Ash.Resource.Change
@ -18,14 +18,14 @@ defmodule SpazioSolazzo.Accounts.User.Changes.HandleBookingsOnAccountDeletion do
Booking
|> Ash.Query.filter(
user_id == ^user.id and state == :reserved and date >= ^Date.utc_today()
user_id == ^user.id and state in [:requested, :accepted] and date >= ^Date.utc_today()
)
|> BookingSystem.cancel_booking!()
|> BookingSystem.cancel_booking!("Account deleted by user")
if delete_history do
Booking
|> Ash.Query.filter(user_id == ^user.id)
|> BookingSystem.delete_booking!(authorize?: false)
|> Ash.bulk_destroy!(:destroy, %{}, authorize?: false)
end
changeset

View file

@ -1,6 +1,6 @@
defmodule SpazioSolazzo.BookingSystem do
@moduledoc """
Manages bookings, spaces, assets and time slots for the booking system.
Manages bookings, spaces, and time slots for the booking system.
"""
use Ash.Domain,
@ -9,14 +9,10 @@ defmodule SpazioSolazzo.BookingSystem do
resources do
resource SpazioSolazzo.BookingSystem.Space do
define :get_space_by_slug, action: :read, get_by: [:slug]
define :create_space, action: :create, args: [:name, :slug, :description]
end
resource SpazioSolazzo.BookingSystem.Asset do
define :get_asset_by_id, action: :read, get_by: [:id]
define :get_asset_by_space_id, action: :read, get_by: [:space_id]
define :get_space_assets, action: :get_space_assets, args: [:space_id]
define :create_asset, action: :create, args: [:name, :space_id]
define :create_space,
action: :create,
args: [:name, :slug, :description, :capacity]
end
resource SpazioSolazzo.BookingSystem.TimeSlotTemplate do
@ -30,26 +26,46 @@ defmodule SpazioSolazzo.BookingSystem do
end
resource SpazioSolazzo.BookingSystem.Booking do
define :list_active_asset_bookings_by_date,
action: :list_active_asset_bookings_by_date,
args: [:asset_id, :date]
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,
action: :search,
args: [:space_id, :start_datetime, :end_datetime, :states, :select]
define :create_booking,
action: :create,
args: [
:time_slot_template_id,
:asset_id,
:space_id,
:user_id,
:date,
:start_time,
:end_time,
:customer_name,
:customer_email,
:customer_phone,
:customer_comment
]
define :confirm_booking, action: :confirm_booking, args: []
define :cancel_booking, action: :cancel, args: []
define :delete_booking, action: :destroy, args: []
define :create_walk_in,
action: :create_walk_in,
args: [
:space_id,
:start_datetime,
:end_datetime,
:customer_name,
:customer_email,
:customer_phone
]
define :approve_booking, action: :approve, args: []
define :reject_booking, action: :reject, args: [:reason]
define :cancel_booking, action: :cancel, args: [:reason]
end
end
end

View file

@ -1,43 +0,0 @@
defmodule SpazioSolazzo.BookingSystem.Asset do
@moduledoc """
Represents bookable assets within a space, such as rooms or equipment.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer
postgres do
table "assets"
repo SpazioSolazzo.Repo
end
actions do
defaults [:read, create: :*]
read :get_space_assets do
argument :space_id, :string do
allow_nil? false
end
filter expr(space_id == ^arg(:space_id))
end
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
end
relationships do
belongs_to :space, SpazioSolazzo.BookingSystem.Space do
allow_nil? false
public? true
end
end
identities do
identity :unique_name_per_space, [:name, :space_id]
end
end

View file

@ -11,7 +11,13 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
authorizers: [Ash.Policy.Authorizer],
extensions: [AshStateMachine]
alias SpazioSolazzo.BookingSystem.Booking.EmailWorker
require Ash.Query
alias SpazioSolazzo.BookingSystem.Booking.{
AdminActionEmailWorker,
RequestCreatedEmailWorker,
UserCancellationEmailWorker
}
postgres do
table "bookings"
@ -20,112 +26,335 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
references do
reference :user, on_delete: :nilify, index?: true
end
custom_indexes do
# Composite index for space + datetime range queries (most common pattern)
index [:space_id, :start_datetime, :end_datetime]
# Composite index for space + state queries (filtering by status)
index [:space_id, :state]
# Single indexes for datetime overlap queries
index [:start_datetime]
index [:end_datetime]
end
end
state_machine do
initial_states([:reserved])
default_initial_state(:reserved)
initial_states([:requested])
default_initial_state(:requested)
transitions do
transition(:confirm_booking, from: :reserved, to: :completed)
transition(:cancel, from: :reserved, to: :cancelled)
transition(:approve, from: :requested, to: :accepted)
transition(:reject, from: :requested, to: :rejected)
transition(:cancel, from: [:requested, :accepted], to: :cancelled)
end
end
actions do
defaults [:read]
read :list_active_asset_bookings_by_date do
argument :asset_id, :uuid, allow_nil?: false
argument :date, :date, allow_nil?: false
read :search do
description "Fetch bookings within a date/time range with optional filters"
filter expr(
asset_id == ^arg(:asset_id) and date == ^arg(:date) and
state in [:reserved, :completed]
)
argument :space_id, :uuid, allow_nil?: true
argument :start_datetime, :datetime, allow_nil?: false
argument :end_datetime, :datetime, allow_nil?: false
argument :states, {:array, :atom}, allow_nil?: true
argument :select, {:array, :atom}, allow_nil?: true
prepare fn query, _ctx ->
start_dt = Ash.Query.get_argument(query, :start_datetime)
end_dt = Ash.Query.get_argument(query, :end_datetime)
# Base datetime overlap filter
query =
Ash.Query.filter(
query,
start_datetime < ^end_dt and end_datetime > ^start_dt
)
# Optional space filter
query =
case Ash.Query.get_argument(query, :space_id) do
nil -> query
space_id -> Ash.Query.filter(query, space_id == ^space_id)
end
# Optional states filter
query =
case Ash.Query.get_argument(query, :states) do
nil -> query
[] -> query
states -> Ash.Query.filter(query, state in ^states)
end
case Ash.Query.get_argument(query, :select) do
nil -> query
[] -> query
select -> Ash.Query.select(query, select)
end
end
end
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
# 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.Booking.Preparations.ApplyAdminFilters
prepare fn query, _ctx ->
Ash.Query.sort(query, inserted_at: :desc)
end
end
read :read_booking_history do
description "Fetch historical bookings (accepted/rejected/cancelled) with pagination"
argument :space_id, :uuid, allow_nil?: true
argument :email, :string, allow_nil?: true
argument :date, :date, allow_nil?: true
# Non-pending states
filter expr(state in [:accepted, :rejected, :cancelled])
pagination do
required? false
offset? true
countable true
default_limit 25
max_page_size 100
end
# Apply shared admin filters preparation
prepare SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters
prepare fn query, _ctx ->
Ash.Query.sort(query, start_datetime: :desc)
end
end
create :create do
argument :time_slot_template_id, :uuid, allow_nil?: false
argument :asset_id, :uuid, allow_nil?: false
argument :user_id, :uuid, allow_nil?: false
argument :space_id, :uuid, allow_nil?: false
argument :user_id, :uuid, allow_nil?: true
argument :date, :date, allow_nil?: false
argument :start_time, :time, allow_nil?: false
argument :end_time, :time, allow_nil?: false
argument :customer_name, :string, allow_nil?: false
argument :customer_email, :string, allow_nil?: false
argument :customer_phone, :string, allow_nil?: true
argument :customer_comment, :string, allow_nil?: true
change manage_relationship(:time_slot_template_id, :time_slot_template,
type: :append_and_remove
)
change manage_relationship(:asset_id, :asset, type: :append_and_remove)
change manage_relationship(:space_id, :space, type: :append_and_remove)
change manage_relationship(:user_id, :user, type: :append_and_remove, authorize?: false)
validate {SpazioSolazzo.BookingSystem.Validations.FutureDate, field: :date}
validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
start: :start_time, end: :end_time}
validate {SpazioSolazzo.BookingSystem.Validations.Email, field: :customer_email}
change fn changeset, _ctx ->
template_id = Ash.Changeset.get_argument(changeset, :time_slot_template_id)
date = Ash.Changeset.get_argument(changeset, :date)
start_time = Ash.Changeset.get_argument(changeset, :start_time)
end_time = Ash.Changeset.get_argument(changeset, :end_time)
case Ash.get(SpazioSolazzo.BookingSystem.TimeSlotTemplate, template_id) do
{:ok, template} ->
changeset
|> Ash.Changeset.force_change_attribute(:start_time, template.start_time)
|> Ash.Changeset.force_change_attribute(:end_time, template.end_time)
|> Ash.Changeset.force_change_attribute(
:date,
Ash.Changeset.get_argument(changeset, :date)
)
|> Ash.Changeset.force_change_attribute(
:customer_name,
Ash.Changeset.get_argument(changeset, :customer_name)
)
|> Ash.Changeset.force_change_attribute(
:customer_email,
Ash.Changeset.get_argument(changeset, :customer_email)
)
|> Ash.Changeset.force_change_attribute(
:customer_phone,
Ash.Changeset.get_argument(changeset, :customer_phone)
)
|> Ash.Changeset.force_change_attribute(
:customer_comment,
Ash.Changeset.get_argument(changeset, :customer_comment)
)
start_datetime = DateTime.new!(date, start_time, "Etc/UTC")
end_datetime = DateTime.new!(date, end_time, "Etc/UTC")
{:error, _} ->
Ash.Changeset.add_error(changeset,
field: :time_slot_template_id,
message: "Template not found"
)
end
changeset
|> Ash.Changeset.force_change_attribute(:start_datetime, start_datetime)
|> Ash.Changeset.force_change_attribute(:end_datetime, end_datetime)
|> Ash.Changeset.force_change_attribute(:date, date)
|> Ash.Changeset.force_change_attribute(:start_time, start_time)
|> Ash.Changeset.force_change_attribute(:end_time, end_time)
|> Ash.Changeset.force_change_attribute(
:customer_name,
Ash.Changeset.get_argument(changeset, :customer_name)
)
|> Ash.Changeset.force_change_attribute(
:customer_email,
Ash.Changeset.get_argument(changeset, :customer_email)
)
|> Ash.Changeset.force_change_attribute(
:customer_phone,
Ash.Changeset.get_argument(changeset, :customer_phone)
)
|> Ash.Changeset.force_change_attribute(
:customer_comment,
Ash.Changeset.get_argument(changeset, :customer_comment)
)
end
change after_action(fn _changeset, booking, _ctx ->
booking = Ash.load!(booking, [:space])
%{
booking_id: booking.id,
customer_name: booking.customer_name,
customer_email: booking.customer_email,
customer_phone: booking.customer_phone,
customer_comment: booking.customer_comment,
date: Calendar.strftime(booking.date, "%A, %B %d"),
start_time: booking.start_time,
end_time: booking.end_time
space_name: booking.space.name,
start_datetime: booking.start_datetime,
end_datetime: booking.end_datetime
}
|> EmailWorker.new()
|> RequestCreatedEmailWorker.new()
|> Oban.insert!()
{:ok, booking}
end)
end
update :confirm_booking do
create :create_walk_in do
argument :space_id, :uuid, allow_nil?: false
argument :start_datetime, :datetime, allow_nil?: false
argument :end_datetime, :datetime, allow_nil?: false
argument :customer_name, :string, allow_nil?: false
argument :customer_email, :string, allow_nil?: false
argument :customer_phone, :string, allow_nil?: true
change manage_relationship(:space_id, :space, type: :append_and_remove)
validate {SpazioSolazzo.BookingSystem.Validations.FutureDate, field: :end_datetime}
validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
start: :start_datetime, end: :end_datetime}
validate {SpazioSolazzo.BookingSystem.Validations.Email, field: :customer_email}
change fn changeset, _ctx ->
start_datetime = Ash.Changeset.get_argument(changeset, :start_datetime)
end_datetime = Ash.Changeset.get_argument(changeset, :end_datetime)
date = DateTime.to_date(start_datetime)
start_time = DateTime.to_time(start_datetime)
end_time = DateTime.to_time(end_datetime)
changeset
|> Ash.Changeset.force_change_attribute(:start_datetime, start_datetime)
|> Ash.Changeset.force_change_attribute(:end_datetime, end_datetime)
|> Ash.Changeset.force_change_attribute(:date, date)
|> Ash.Changeset.force_change_attribute(:start_time, start_time)
|> Ash.Changeset.force_change_attribute(:end_time, end_time)
|> Ash.Changeset.force_change_attribute(:state, :accepted)
|> Ash.Changeset.force_change_attribute(
:customer_name,
Ash.Changeset.get_argument(changeset, :customer_name)
)
|> Ash.Changeset.force_change_attribute(
:customer_email,
Ash.Changeset.get_argument(changeset, :customer_email)
)
|> Ash.Changeset.force_change_attribute(
:customer_phone,
Ash.Changeset.get_argument(changeset, :customer_phone)
)
end
end
update :approve do
accept []
change transition_state(:completed)
require_atomic? false
change transition_state(:accepted)
change after_action(fn _changeset, booking, _ctx ->
booking = Ash.load!(booking, [:space])
%{
booking_id: booking.id,
customer_name: booking.customer_name,
customer_email: booking.customer_email,
customer_phone: booking.customer_phone,
space_name: booking.space.name,
start_datetime: booking.start_datetime,
end_datetime: booking.end_datetime,
action: "accepted"
}
|> AdminActionEmailWorker.new()
|> Oban.insert!()
{:ok, booking}
end)
end
update :reject do
accept [:rejection_reason]
argument :reason, :string, allow_nil?: false
require_atomic? false
change fn changeset, _ctx ->
reason = Ash.Changeset.get_argument(changeset, :reason)
Ash.Changeset.force_change_attribute(changeset, :rejection_reason, reason)
end
change transition_state(:rejected)
change after_action(fn _changeset, booking, _ctx ->
booking = Ash.load!(booking, [:space])
%{
customer_name: booking.customer_name,
customer_email: booking.customer_email,
customer_phone: booking.customer_phone,
space_name: booking.space.name,
start_datetime: booking.start_datetime,
end_datetime: booking.end_datetime,
action: "rejected",
rejection_reason: booking.rejection_reason
}
|> AdminActionEmailWorker.new()
|> Oban.insert!()
{:ok, booking}
end)
end
update :cancel do
accept []
accept [:cancellation_reason]
argument :reason, :string, allow_nil?: false
require_atomic? false
change fn changeset, _ctx ->
reason = Ash.Changeset.get_argument(changeset, :reason)
Ash.Changeset.force_change_attribute(changeset, :cancellation_reason, reason)
end
change transition_state(:cancelled)
change after_action(fn _changeset, booking, _ctx ->
booking = Ash.load!(booking, [:space])
%{
customer_name: booking.customer_name,
customer_email: booking.customer_email,
customer_phone: booking.customer_phone,
space_name: booking.space.name,
start_datetime: booking.start_datetime,
end_datetime: booking.end_datetime,
cancellation_reason: booking.cancellation_reason
}
|> UserCancellationEmailWorker.new()
|> Oban.insert!()
{:ok, booking}
end)
end
destroy :destroy do
@ -135,7 +364,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
policies do
policy action([:cancel, :confirm_booking]) do
policy action([:cancel, :approve, :reject]) do
authorize_if always()
end
@ -157,11 +386,16 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
prefix "booking"
publish :create, ["created"]
publish :create_walk_in, ["created"]
publish :approve, ["approved"]
publish :reject, ["rejected"]
publish :cancel, ["cancelled"]
end
attributes do
uuid_primary_key :id
attribute :start_datetime, :datetime, allow_nil?: false
attribute :end_datetime, :datetime, allow_nil?: false
attribute :date, :date, allow_nil?: false
attribute :customer_name, :string, allow_nil?: false
attribute :customer_email, :string, allow_nil?: false
@ -169,12 +403,14 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
attribute :end_time, :time, allow_nil?: false
attribute :customer_phone, :string, allow_nil?: true
attribute :customer_comment, :string, allow_nil?: true
attribute :cancellation_reason, :string, allow_nil?: true
attribute :rejection_reason, :string, allow_nil?: true
attribute :state, :atom do
allow_nil? false
default :reserved
default :requested
public? true
constraints one_of: [:reserved, :completed, :cancelled]
constraints one_of: [:requested, :accepted, :rejected, :cancelled]
end
create_timestamp :inserted_at
@ -182,8 +418,10 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
relationships do
belongs_to :asset, SpazioSolazzo.BookingSystem.Asset
belongs_to :time_slot_template, SpazioSolazzo.BookingSystem.TimeSlotTemplate
belongs_to :space, SpazioSolazzo.BookingSystem.Space do
allow_nil? false
public? true
end
belongs_to :user, SpazioSolazzo.Accounts.User do
allow_nil? true

View file

@ -0,0 +1,76 @@
defmodule SpazioSolazzo.BookingSystem.Booking.AdminActionEmailWorker do
@moduledoc """
Sends emails when an admin approves or rejects a booking request.
"""
use Oban.Worker, queue: :booking_email, max_attempts: 3
alias SpazioSolazzo.BookingSystem.Booking.Email
alias SpazioSolazzo.CalendarExt
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"booking_id" => booking_id,
"customer_name" => customer_name,
"customer_email" => customer_email,
"customer_phone" => customer_phone,
"space_name" => space_name,
"start_datetime" => start_datetime_str,
"end_datetime" => end_datetime_str,
"action" => "accepted"
}
}) do
{:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
{:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
%{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
space_name: space_name,
start_datetime: start_datetime,
end_datetime: end_datetime,
date: CalendarExt.format_datetime_date_only(start_datetime),
start_time: DateTime.to_time(start_datetime),
end_time: DateTime.to_time(end_datetime)
}
|> Email.booking_request_approved()
|> SpazioSolazzo.Mailer.deliver!()
:ok
end
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"customer_name" => customer_name,
"customer_email" => customer_email,
"space_name" => space_name,
"start_datetime" => start_datetime_str,
"end_datetime" => end_datetime_str,
"action" => "rejected",
"rejection_reason" => rejection_reason
}
}) do
{:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
{:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
%{
customer_name: customer_name,
customer_email: customer_email,
space_name: space_name,
start_datetime: start_datetime,
end_datetime: end_datetime,
date: CalendarExt.format_datetime_date_only(start_datetime),
start_time: DateTime.to_time(start_datetime),
end_time: DateTime.to_time(end_datetime),
rejection_reason: rejection_reason
}
|> Email.booking_request_rejected()
|> SpazioSolazzo.Mailer.deliver!()
:ok
end
end

View file

@ -11,12 +11,13 @@ defmodule SpazioSolazzo.BookingSystem.Booking.Email do
use SpazioSolazzoWeb, :verified_routes
alias SpazioSolazzo.BookingSystem.Booking.Token
def customer_confirmation(%{
def user_booking_request_confirmation(%{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time
@ -29,56 +30,144 @@ defmodule SpazioSolazzo.BookingSystem.Booking.Email do
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
cancel_url: cancel_url,
front_office_phone_number: front_office_phone_number(),
subject: "Booking Confirmed: #{date}"
subject: "Request Received: #{date}"
}
new()
|> to({customer_name, customer_email})
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("customer_confirmation.html", assigns)
|> render_body("user_booking_request_confirmation.html", assigns)
end
# --- Admin Email ---
def admin_notification(%{
booking_id: booking_id,
def admin_incoming_booking_request(%{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
admin_email: admin_email
}) do
tokens = Token.generate_admin_tokens(booking_id)
confirm_url = url(~p"/bookings/confirm?token=#{tokens.confirm_token}")
cancel_url = url(~p"/bookings/cancel?token=#{tokens.cancel_token}")
dashboard_url = url(~p"/admin/bookings")
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
confirm_url: confirm_url,
cancel_url: cancel_url,
subject: "New Booking Action Required: #{customer_name}"
dashboard_url: dashboard_url,
subject: "New Booking Request: #{customer_name}"
}
new()
|> to(admin_email)
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("admin_notification.html", assigns)
|> render_body("admin_incoming_booking_request.html", assigns)
end
def booking_cancelled(%{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
cancellation_reason: cancellation_reason,
admin_email: admin_email
}) do
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
cancellation_reason: cancellation_reason,
subject: "Booking Cancelled: #{customer_name}"
}
new()
|> to(admin_email)
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("booking_cancelled.html", assigns)
end
def booking_request_approved(%{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time
}) do
cancel_token = Token.generate_customer_cancel_token(booking_id)
cancel_url = url(~p"/bookings/cancel?token=#{cancel_token}")
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
cancel_url: cancel_url,
front_office_phone_number: front_office_phone_number(),
subject: "Booking Approved: #{date}"
}
new()
|> to({customer_name, customer_email})
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("booking_request_approved.html", assigns)
end
def booking_request_rejected(%{
customer_name: customer_name,
customer_email: customer_email,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
rejection_reason: rejection_reason
}) do
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
rejection_reason: rejection_reason,
front_office_phone_number: front_office_phone_number(),
subject: "Booking Request Update: #{date}"
}
new()
|> to({customer_name, customer_email})
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("booking_request_rejected.html", assigns)
end
defp spazio_solazzo_email do

View file

@ -1,49 +0,0 @@
defmodule SpazioSolazzo.BookingSystem.Booking.EmailWorker do
@moduledoc """
Sends booking confirmation emails to customers and notification emails to administrators.
"""
use Oban.Worker, queue: :booking_email, max_attempts: 1
alias SpazioSolazzo.BookingSystem.Booking.Email
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"booking_id" => booking_id,
"customer_name" => customer_name,
"customer_email" => customer_email,
"customer_phone" => customer_phone,
"customer_comment" => customer_comment,
"date" => date,
"start_time" => start_time,
"end_time" => end_time
}
}) do
email_data = %{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
date: date,
start_time: start_time,
end_time: end_time,
admin_email: admin_email()
}
email_data
|> Email.customer_confirmation()
|> SpazioSolazzo.Mailer.deliver!()
email_data
|> Email.admin_notification()
|> SpazioSolazzo.Mailer.deliver!()
:ok
end
defp admin_email do
Application.get_env(:spazio_solazzo, :admin_email)
end
end

View file

@ -0,0 +1,40 @@
defmodule SpazioSolazzo.BookingSystem.Booking.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,57 @@
defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorker do
@moduledoc """
Sends booking request confirmation emails to customers and notification emails to administrators.
Triggered when a new booking request is created.
"""
use Oban.Worker, queue: :booking_email, max_attempts: 3
alias SpazioSolazzo.BookingSystem.Booking.Email
alias SpazioSolazzo.CalendarExt
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"booking_id" => booking_id,
"customer_name" => customer_name,
"customer_email" => customer_email,
"customer_phone" => customer_phone,
"customer_comment" => customer_comment,
"space_name" => space_name,
"start_datetime" => start_datetime_str,
"end_datetime" => end_datetime_str
}
}) do
{:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
{:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
email_data = %{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
space_name: space_name,
start_datetime: start_datetime,
end_datetime: end_datetime,
date: CalendarExt.format_datetime_date_only(start_datetime),
start_time: DateTime.to_time(start_datetime),
end_time: DateTime.to_time(end_datetime),
admin_email: admin_email()
}
email_data
|> Email.user_booking_request_confirmation()
|> SpazioSolazzo.Mailer.deliver!()
email_data
|> Email.admin_incoming_booking_request()
|> SpazioSolazzo.Mailer.deliver!()
:ok
end
defp admin_email do
Application.get_env(:spazio_solazzo, :admin_email)
end
end

View file

@ -0,0 +1,48 @@
defmodule SpazioSolazzo.BookingSystem.Booking.UserCancellationEmailWorker do
@moduledoc """
Sends cancellation notification emails to administrators when a customer cancels a booking.
"""
use Oban.Worker, queue: :booking_email, max_attempts: 3
alias SpazioSolazzo.BookingSystem.Booking.Email
alias SpazioSolazzo.CalendarExt
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"customer_name" => customer_name,
"customer_email" => customer_email,
"customer_phone" => customer_phone,
"space_name" => space_name,
"start_datetime" => start_datetime_str,
"end_datetime" => end_datetime_str,
"cancellation_reason" => cancellation_reason
}
}) do
{:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
{:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
%{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
space_name: space_name,
start_datetime: start_datetime,
end_datetime: end_datetime,
date: CalendarExt.format_datetime_date_only(start_datetime),
start_time: DateTime.to_time(start_datetime),
end_time: DateTime.to_time(end_datetime),
cancellation_reason: cancellation_reason,
admin_email: admin_email()
}
|> Email.booking_cancelled()
|> SpazioSolazzo.Mailer.deliver!()
:ok
end
defp admin_email do
Application.get_env(:spazio_solazzo, :admin_email)
end
end

View file

@ -6,7 +6,8 @@ defmodule SpazioSolazzo.BookingSystem.Space do
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "spaces"
@ -14,7 +15,31 @@ defmodule SpazioSolazzo.BookingSystem.Space do
end
actions do
defaults [:read, create: :*]
defaults [:read]
create :create do
accept [:name, :description, :slug, :capacity]
validate fn changeset, _ctx ->
capacity = Ash.Changeset.get_attribute(changeset, :capacity)
if capacity && capacity <= 0 do
{:error, field: :capacity, message: "must be greater than 0"}
else
:ok
end
end
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if always()
end
end
attributes do
@ -22,6 +47,7 @@ defmodule SpazioSolazzo.BookingSystem.Space do
attribute :name, :string, allow_nil?: false, public?: true
attribute :description, :string, allow_nil?: false, public?: true
attribute :slug, :string, allow_nil?: false, public?: true
attribute :capacity, :integer, allow_nil?: false, public?: true
end
identities do

View file

@ -20,6 +20,10 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate do
create :create do
accept [:start_time, :end_time, :space_id, :day_of_week]
validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
start: :start_time, end: :end_time}
change {Changes.PreventCreationOverlap, []}
end
@ -55,4 +59,17 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate do
public? true
end
end
calculations do
calculate :booking_stats,
:map,
{SpazioSolazzo.BookingSystem.TimeSlotTemplate.Calculations.SlotBookingStats, []} do
description "Calculates booking counts and availability for this time slot on a specific date"
argument :date, :date, allow_nil?: false
argument :space_id, :uuid, allow_nil?: false
argument :capacity, :integer, allow_nil?: false
argument :user_id, :uuid, allow_nil?: true
end
end
end

View file

@ -0,0 +1,66 @@
defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate.Calculations.SlotBookingStats do
@moduledoc """
Calculates booking statistics for time slots by fetching all bookings for the day once,
then filtering in memory. This eliminates N+1 query problems.
"""
use Ash.Resource.Calculation
@impl true
def load(_query, _opts, _context) do
[:start_time, :end_time, :space_id]
end
@impl true
def calculate(records, _opts, %{arguments: arguments}) do
date = Map.get(arguments, :date)
space_id = Map.get(arguments, :space_id)
capacity = Map.get(arguments, :capacity)
user_id = Map.get(arguments, :user_id)
# Fetch all bookings for the entire day ONCE
day_start = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
day_end = DateTime.new!(date, ~T[23:59:59], "Etc/UTC")
{:ok, all_bookings} =
SpazioSolazzo.BookingSystem.search_bookings(
space_id,
day_start,
day_end,
[:requested, :accepted],
[:start_datetime, :end_datetime, :state, :user_id]
)
# Calculate stats for each slot using the cached bookings
Enum.map(records, fn slot ->
slot_start = DateTime.new!(date, slot.start_time, "Etc/UTC")
slot_end = DateTime.new!(date, slot.end_time, "Etc/UTC")
# Filter bookings that overlap with this slot
overlapping =
Enum.filter(all_bookings, fn booking ->
DateTime.compare(booking.start_datetime, slot_end) == :lt and
DateTime.compare(booking.end_datetime, slot_start) == :gt
end)
requested_count = Enum.count(overlapping, &(&1.state == :requested))
accepted_count = Enum.count(overlapping, &(&1.state == :accepted))
user_has_booking =
if user_id do
Enum.any?(overlapping, &(&1.user_id == user_id))
else
false
end
availability = if accepted_count >= capacity, do: :over_capacity, else: :available
%{
requested_count: requested_count,
accepted_count: accepted_count,
user_has_booking: user_has_booking,
availability_status: availability
}
end)
end
end

View file

@ -18,28 +18,32 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate.Changes.PreventCreationOv
end_time = Ash.Changeset.get_attribute(changeset, :end_time)
day_of_week = Ash.Changeset.get_attribute(changeset, :day_of_week)
overlapping =
TimeSlotTemplate
|> Ash.Query.filter(space_id == ^space_id)
|> Ash.Query.filter(day_of_week == ^day_of_week)
|> Ash.Query.filter(start_time < ^end_time and end_time > ^start_time)
|> Ash.read()
if is_nil(day_of_week) do
changeset
else
overlapping =
TimeSlotTemplate
|> Ash.Query.filter(space_id == ^space_id)
|> Ash.Query.filter(day_of_week == ^day_of_week)
|> Ash.Query.filter(start_time < ^end_time and end_time > ^start_time)
|> Ash.read()
case overlapping do
{:ok, []} ->
changeset
case overlapping do
{:ok, []} ->
changeset
{:ok, _} ->
Changeset.add_error(changeset,
field: :base,
message: "time slot overlaps with existing template for this space and day"
)
{:ok, _} ->
Changeset.add_error(changeset,
field: :base,
message: "time slot overlaps with existing template for this space and day"
)
{:error, err} ->
Changeset.add_error(changeset,
field: :base,
message: "failed to validate overlap: #{inspect(err)}"
)
{:error, err} ->
Changeset.add_error(changeset,
field: :base,
message: "failed to validate overlap: #{inspect(err)}"
)
end
end
end
end

View file

@ -0,0 +1,38 @@
defmodule SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder do
@moduledoc """
Validates that an end time/datetime occurs after a start time/datetime.
"""
use Ash.Resource.Validation
@impl true
def init(opts) do
if Keyword.has_key?(opts, :start) && Keyword.has_key?(opts, :end) do
{:ok, opts}
else
{:error, "Both `start` and `end` options are required."}
end
end
@impl true
def validate(changeset, opts, _context) do
start_field = opts[:start]
end_field = opts[:end]
start_val = get_value(changeset, start_field)
end_val = get_value(changeset, end_field)
if start_val && end_val && !after?(end_val, start_val) do
{:error, field: end_field, message: "must be after #{start_field}"}
else
:ok
end
end
defp after?(%Time{} = a, %Time{} = b), do: Time.compare(a, b) == :gt
defp after?(%DateTime{} = a, %DateTime{} = b), do: DateTime.compare(a, b) == :gt
defp after?(_, _), do: true
defp get_value(changeset, field) do
Ash.Changeset.get_argument(changeset, field) || Ash.Changeset.get_attribute(changeset, field)
end
end

View file

@ -0,0 +1,31 @@
defmodule SpazioSolazzo.BookingSystem.Validations.Email do
@moduledoc """
Validates that a field contains a valid email address.
"""
use Ash.Resource.Validation
@impl true
def init(opts) do
if Keyword.has_key?(opts, :field) do
{:ok, opts}
else
{:error, "The `field` option is required."}
end
end
@impl true
def validate(changeset, opts, _context) do
field = opts[:field]
value = get_value(changeset, field)
if value && !String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
{:error, field: field, message: "must be a valid email"}
else
:ok
end
end
defp get_value(changeset, field) do
Ash.Changeset.get_argument(changeset, field) || Ash.Changeset.get_attribute(changeset, field)
end
end

View file

@ -0,0 +1,35 @@
defmodule SpazioSolazzo.BookingSystem.Validations.FutureDate do
@moduledoc """
Validates that a date or datetime is in the future relative to UTC now/today.
"""
use Ash.Resource.Validation
@impl true
def init(opts) do
if Keyword.has_key?(opts, :field) do
{:ok, opts}
else
{:error, "The `field` option is required."}
end
end
@impl true
def validate(changeset, opts, _context) do
field = opts[:field]
value = get_value(changeset, field)
if value && in_past?(value) do
{:error, field: field, message: "cannot be in the past"}
else
:ok
end
end
defp in_past?(%Date{} = date), do: Date.compare(date, Date.utc_today()) == :lt
defp in_past?(%DateTime{} = dt), do: DateTime.compare(dt, DateTime.utc_now()) == :lt
defp in_past?(_), do: false
defp get_value(changeset, field) do
Ash.Changeset.get_argument(changeset, field) || Ash.Changeset.get_attribute(changeset, field)
end
end

View file

@ -13,4 +13,132 @@ defmodule SpazioSolazzo.CalendarExt do
"#{start_time} - #{end_time}"
end
@doc """
Formats a datetime as "Feb 10, 2026"
"""
def format_datetime_date(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%b %d, %Y")
end
@doc """
Formats a datetime as "Monday, February 10, 2026"
"""
def format_datetime_date_long(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%A, %B %d, %Y")
end
@doc """
Formats a datetime as "Monday, February 10" (for emails)
"""
def format_datetime_date_only(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%A, %B %d")
end
@doc """
Formats a time or datetime as "9:00 AM"
"""
def format_time(%DateTime{} = datetime) do
datetime
|> DateTime.to_time()
|> format_time()
end
def format_time(%Time{} = time) do
Calendar.strftime(time, "%I:%M %p")
end
@doc """
Formats a time range as "9:00 AM - 5:00 PM"
Takes two Time or DateTime structs
"""
def format_time_range(%DateTime{} = start_dt, %DateTime{} = end_dt) do
start_time = DateTime.to_time(start_dt)
end_time = DateTime.to_time(end_dt)
format_time_range(start_time, end_time)
end
def format_time_range(%Time{} = start_time, %Time{} = end_time) do
"#{format_time(start_time)} - #{format_time(end_time)}"
end
@doc """
Checks if a booking spans multiple days
"""
def multi_day?(%DateTime{} = start_datetime, %DateTime{} = end_datetime) do
start_date = DateTime.to_date(start_datetime)
end_date = DateTime.to_date(end_datetime)
Date.compare(start_date, end_date) != :eq
end
@doc """
Formats a datetime range handling both single-day and multi-day bookings.
Single-day: "Feb 10, 2026 9:00 AM - 5:00 PM"
Multi-day: "Feb 10, 2026 9:00 AM - Feb 15, 2026 5:00 PM"
"""
def format_datetime_range(%DateTime{} = start_datetime, %DateTime{} = end_datetime) do
if multi_day?(start_datetime, end_datetime) do
"#{format_datetime_date(start_datetime)} #{format_time(start_datetime)} - #{format_datetime_date(end_datetime)} #{format_time(end_datetime)}"
else
"#{format_datetime_date(start_datetime)} #{format_time_range(start_datetime, end_datetime)}"
end
end
@doc """
Formats the start portion of a datetime range for table display
Single-day: "Feb 10, 2026 9:00 AM"
Multi-day: "Feb 10, 2026 9:00 AM"
"""
def format_datetime_range_start(%DateTime{} = datetime) do
"#{format_datetime_date(datetime)} #{format_time(datetime)}"
end
@doc """
Formats the end portion of a datetime range for table display
Single-day: "5:00 PM" (date not shown)
Multi-day: "Feb 15, 2026 5:00 PM"
"""
def format_datetime_range_end(%DateTime{} = start_datetime, %DateTime{} = end_datetime) do
if multi_day?(start_datetime, end_datetime) do
"#{format_datetime_date(end_datetime)} #{format_time(end_datetime)}"
else
format_time(end_datetime)
end
end
# There are 7 days displayed in the calendar
@grid_cols 7
# The calendar can show max 6 weeks for one month
@grid_rows 6
@doc """
Build a list containing all the dates to be displayed in a
Calendar grid.
6 weeks * 7 days = 42 cells
"""
def build_calendar_grid(date) do
first_day = Date.beginning_of_month(date)
# Mon=1, Sun=7
start_day_of_week = Date.day_of_week(first_day)
# Calculate days to subtract to get to the previous Monday
# If starts on Mon (1), sub 0. If Sun (7), sub 6.
days_to_sub = start_day_of_week - 1
start_date = Date.add(first_day, -days_to_sub)
# 6 weeks * 7 days = 42 grid cells
Enum.map(0..(@grid_cols * @grid_rows - 1), fn i -> Date.add(start_date, i) end)
end
@doc "Checks if a date is within a start/end range (inclusive)"
def date_in_range?(date, start_date, end_date)
when not is_nil(start_date) and not is_nil(end_date) do
Date.compare(date, start_date) != :lt and Date.compare(date, end_date) != :gt
end
def date_in_range?(_date, _start, _end), do: false
end

View file

@ -0,0 +1,233 @@
defmodule SpazioSolazzoWeb.AdminBookingManagementComponents 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

@ -0,0 +1,65 @@
defmodule SpazioSolazzoWeb.AdminDashboardComponents do
@moduledoc """
Reusable components for the booking flow.
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
attr :title, :string, required: true
attr :description, :string, required: true
attr :color, :atom, values: [:primary, :secondary], required: true
attr :icon, :string, required: true
attr :navigate, :string, required: true
@doc """
Renders a tool card to be displayed in the admin dashboard
"""
def tool_card(assigns) do
~H"""
<.link
navigate={@navigate}
class={"group bg-white dark:bg-slate-800 rounded-3xl p-8 border-2 border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none #{container_color_class(@color)} transition-all duration-300 hover:scale-[1.02]"}
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-6">
<div class={"size-16 rounded-2xl #{icon_color_class(@color)} flex items-center justify-center group-hover:scale-110 transition-transform duration-300"}>
<.icon name={@icon} class="w-8 h-8" />
</div>
</div>
<h2 class={"text-2xl font-bold text-slate-900 dark:text-white mb-3 #{title_color_class(@color)} transition-colors"}>
{@title}
</h2>
<p class="text-slate-600 dark:text-slate-400 mb-6 flex-grow">
{@description}
</p>
<div class={"flex items-center #{tooltip_color_class(@color)} font-semibold group-hover:gap-3 transition-all"}>
<span>Open Tool</span>
<.icon
name="hero-arrow-right"
class="w-5 h-5 group-hover:translate-x-1 transition-transform"
/>
</div>
</div>
</.link>
"""
end
defp container_color_class(:primary), do: "hover:border-primary dark:hover:border-primary"
defp container_color_class(:secondary), do: "hover:border-sky-500 dark:hover:border-sky-400"
defp icon_color_class(:primary),
do: "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
defp icon_color_class(:secondary),
do: "bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 "
defp title_color_class(:primary), do: "group-hover:text-primary dark:group-hover:text-primary"
defp title_color_class(:secondary), do: "group-hover:text-sky-500 dark:group-hover:text-sky-400"
defp tooltip_color_class(:primary), do: "text-primary dark:text-primary-hover"
defp tooltip_color_class(:secondary), do: "text-sky-500 dark:text-sky-400"
end

View file

@ -1,38 +0,0 @@
defmodule SpazioSolazzoWeb.AdminComponents do
@moduledoc """
Reusable components for admin pages (dashboard & tools).
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1, button: 1]
import Phoenix.Component
use Phoenix.VerifiedRoutes,
endpoint: SpazioSolazzoWeb.Endpoint,
router: SpazioSolazzoWeb.Router,
statics: SpazioSolazzoWeb.static_paths()
@doc """
Cards displayed for each tool available to admins
"""
attr :id, :string, doc: "optional id for the tool"
attr :title, :string
attr :icon, :string, doc: "Icon used to represent the type of tool"
attr :description, :string
def tool_card(assigns) do
~H"""
<div class="card bg-base-100 text-base-content shadow-xl border border-base-200 hover:shadow-2xl transition-shadow cursor-pointer">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<.icon name={@icon} class="size-6 text-secondary" /> {@title}
</h2>
<p class="text-base-content/70 mt-2">{@description}</p>
<div class="card-actions justify-end mt-4">
<.button class="btn btn-primary btn-sm">Open</.button>
</div>
</div>
</div>
"""
end
end

View file

@ -3,110 +3,95 @@ defmodule SpazioSolazzoWeb.BookingComponents do
Reusable components for the booking flow.
"""
use Phoenix.Component
alias SpazioSolazzo.CalendarExt
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_close, :any, required: true
@doc """
Success modal displayed when a booking is completed.
"""
def booking_confirmation_modal(assigns) do
~H"""
<div
:if={@show}
id={@id}
class="relative z-50"
role="dialog"
aria-modal="true"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
id={"#{@id}-container"}
class="relative transform overflow-hidden rounded-3xl bg-base-100 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"
>
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
<svg
class="h-6 w-6 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-lg font-semibold leading-6 text-base-content">
Booking Successful!
</h3>
<div class="mt-2">
<p class="text-sm text-neutral">
Your booking has been confirmed. You will receive a confirmation email shortly.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
phx-click={@on_close}
type="button"
class="btn btn-success w-full rounded-2xl text-white shadow-lg hover:shadow-xl transition-all"
>
Got it!
</button>
</div>
</div>
</div>
</div>
</div>
"""
end
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
attr :time_slot, :map, required: true
attr :booked, :boolean, required: true
@doc """
Renders time slot buttons in different sizes showing availability status.
Renders a detailed time slot card showing availability status and booking counts.
"""
def time_slot(assigns) do
def time_slot_card(assigns) do
assigns =
assigns
|> assign(:availability, assigns.time_slot.booking_stats.availability_status)
|> assign(:requested_count, assigns.time_slot.booking_stats.requested_count)
|> assign(:accepted_count, assigns.time_slot.booking_stats.accepted_count)
|> assign(:user_has_booking, assigns.time_slot.booking_stats.user_has_booking)
~H"""
<button
phx-click={unless @booked, do: "select_slot"}
phx-click={if @user_has_booking, do: nil, else: "select_slot"}
phx-value-time_slot_id={@time_slot.id}
disabled={@booked}
disabled={@user_has_booking}
class={[
"group w-full flex items-center justify-between p-4 rounded-xl border-2 transition-all duration-200",
if(@booked,
do: "border-base-300 bg-base-200 cursor-not-allowed opacity-75",
"w-full p-4 rounded-xl border-2 transition-all duration-200 text-left",
if(@user_has_booking,
do:
"border-slate-200 bg-slate-100 cursor-not-allowed opacity-60 dark:bg-slate-700/50 dark:border-slate-600",
else:
"border-secondary/40 hover:border-secondary bg-transparent hover:bg-secondary/5 cursor-pointer"
if(@availability == :available,
do:
"border-green-200 bg-green-50 hover:border-green-500 hover:shadow-lg cursor-pointer dark:bg-green-900/20 dark:border-green-800 dark:hover:border-green-600",
else:
"border-yellow-200 bg-yellow-50 hover:border-yellow-500 hover:shadow-lg cursor-pointer dark:bg-yellow-900/20 dark:border-yellow-800 dark:hover:border-yellow-600"
)
)
]}
>
<span class={[
"text-lg font-bold transition-colors",
if(@booked,
do: "text-neutral",
else: "text-base-content group-hover:text-secondary"
)
]}>
{CalendarExt.format_time_range(@time_slot)}
</span>
<span class={[
"text-xs font-medium",
if(@booked, do: "text-neutral", else: "text-secondary")
]}>
{if @booked, do: "Booked", else: "Available"}
</span>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="text-lg font-semibold text-slate-900 dark:text-white">
{Calendar.strftime(@time_slot.start_time, "%H:%M")} - {Calendar.strftime(
@time_slot.end_time,
"%H:%M"
)}
</div>
<%= if @user_has_booking do %>
<div class="text-sm text-slate-500 dark:text-slate-400 font-medium mt-1">
Already Requested
</div>
<% else %>
<%= if @availability == :available do %>
<div class="text-sm text-green-600 dark:text-green-400 font-medium mt-1">
Available - Request Booking
</div>
<% else %>
<div class="text-sm text-yellow-600 dark:text-yellow-400 font-medium mt-1">
High Demand - Join Waitlist
</div>
<% end %>
<% end %>
<div class="flex gap-3 mt-2 text-xs text-slate-600 dark:text-slate-400">
<%= if @requested_count > 0 do %>
<span class="flex items-center gap-1">
<.icon name="hero-clock" class="w-3.5 h-3.5" />
{@requested_count} pending
</span>
<% end %>
<%= if @accepted_count > 0 do %>
<span class="flex items-center gap-1">
<.icon name="hero-check-circle" class="w-3.5 h-3.5" />
{@accepted_count} booked
</span>
<% end %>
</div>
</div>
<.icon
name={if @user_has_booking, do: "hero-check", else: "hero-arrow-right"}
class={[
"w-5 h-5",
if(@user_has_booking,
do: "text-slate-400 dark:text-slate-500",
else:
if(@availability == :available,
do: "text-green-500 dark:text-green-400",
else: "text-yellow-500 dark:text-yellow-400"
)
)
]}
/>
</div>
</button>
"""
end

View file

@ -108,7 +108,7 @@ defmodule SpazioSolazzoWeb.CoreComponents do
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :rest, :global, include: ~w(href navigate patch method download name value disabled form)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
@ -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

@ -1,77 +0,0 @@
defmodule SpazioSolazzoWeb.BookingController do
use SpazioSolazzoWeb, :controller
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking
alias SpazioSolazzo.BookingSystem.Booking.Token
def confirm(conn, %{"token" => token}) do
case Token.verify(token) do
{:ok, %{booking_id: booking_id, role: :admin, action: :confirm}} ->
case Ash.get(Booking, booking_id, error?: false) do
{:ok, nil} ->
conn
|> put_flash(:error, "Booking not found, cancelling aborted.")
|> redirect(to: "/")
{:ok, booking} ->
action_result = BookingSystem.confirm_booking(booking)
build_response(conn, action_result, :confirm)
{:error, _} ->
conn
|> put_flash(:error, "Unexpected error occurred, couldn't cancel booking.")
|> redirect(to: "/")
end
_ ->
conn
|> put_flash(:error, "Invalid or expired link.")
|> redirect(to: "/")
end
end
def cancel(conn, %{"token" => token}) do
case Token.verify(token) do
{:ok, %{booking_id: booking_id, role: _, action: :cancel}} ->
case Ash.get(Booking, booking_id, error?: false) do
{:ok, nil} ->
conn
|> put_flash(:error, "Booking not found, cancelling aborted.")
|> redirect(to: "/")
{:ok, booking} ->
action_result = BookingSystem.cancel_booking(booking)
build_response(conn, action_result, :cancel)
{:error, _} ->
conn
|> put_flash(:error, "Unexpected error occurred, couldn't cancel booking.")
|> redirect(to: "/")
end
_ ->
conn
|> put_flash(:error, "Invalid or expired link.")
|> redirect(to: "/")
end
end
defp build_response(conn, action_result, action_name) do
case action_result do
{:ok, _booking} ->
conn
|> put_flash(:info, success_message(action_name))
|> redirect(to: "/")
{:error, _} ->
conn
|> put_flash(:error, "Action could not be completed (e.g. already processed).")
|> redirect(to: "/")
end
end
defp success_message(:cancel), do: "The booking has been cancelled."
defp success_message(:confirm), do: "The booking has been confirmed."
defp success_message(_), do: "Action completed successfully."
end

View file

@ -1,4 +1,4 @@
<h1 class="text-orange">🔔 New Booking Received</h1>
<h1 class="text-orange">🔔 New Booking Request</h1>
<div style="margin-bottom: 20px;">
<p><strong>Customer:</strong> {@customer_name}</p>
@ -7,11 +7,12 @@
<%= if @customer_phone && String.trim(@customer_phone) != "" do %>
<p><strong>Phone:</strong> <a href={"tel:#{@customer_phone}"}>{@customer_phone}</a></p>
<% else %>
<p><strong>Phone:</strong> <a href="#">N/A</a></p>
<p><strong>Phone:</strong> N/A</p>
<% end %>
</div>
<.details_list>
<.detail_item label="Space">{@space_name}</.detail_item>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
</.details_list>
@ -21,7 +22,7 @@
<%= if @customer_comment && String.trim(@customer_comment) != "" do %>
<div style="background-color: #fffaf0; border-left: 4px solid #ed8936; padding: 15px; color: #2d3748; font-size: 16px; font-weight: 500;">
“{@customer_comment}”
"{@customer_comment}"
</div>
<% else %>
<div style="background-color: #f7fafc; padding: 12px; border-radius: 4px; color: #718096; font-style: italic; border: 1px dashed #cbd5e0;">
@ -32,13 +33,9 @@
<hr class="divider" />
<h3 style="color: #5C6BC0; text-align: center;">Admin Actions</h3>
<p class="text-center">Please confirm arrival or cancel the booking.</p>
<h3 style="color: #5C6BC0; text-align: center;">Action Required</h3>
<p class="text-center">Please review and manage this request in the admin dashboard.</p>
<.email_button href={@confirm_url} variant={:primary}>
Confirm Arrival
</.email_button>
<.email_button href={@cancel_url} variant={:danger}>
Cancel Booking
<.email_button href={@dashboard_url} variant={:primary}>
Manage Request
</.email_button>

View file

@ -0,0 +1,31 @@
<h1 style="color: #dc3545;">🚫 Booking Request Cancelled</h1>
<p>A booking request has been cancelled by the customer.</p>
<div style="margin-bottom: 20px;">
<p><strong>Customer:</strong> {@customer_name}</p>
<p><strong>Email:</strong> <a href={"mailto:#{@customer_email}"}>{@customer_email}</a></p>
<%= if @customer_phone && String.trim(@customer_phone) != "" do %>
<p><strong>Phone:</strong> <a href={"tel:#{@customer_phone}"}>{@customer_phone}</a></p>
<% else %>
<p><strong>Phone:</strong> N/A</p>
<% end %>
</div>
<.details_list>
<.detail_item label="Space">{@space_name}</.detail_item>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
</.details_list>
<div style="background-color: #fff5f5; border-left: 4px solid #dc3545; padding: 15px; margin: 20px 0; color: #2d3748;">
<h3 style="color: #dc3545; margin-top: 0; font-size: 16px;">Cancellation Reason:</h3>
<p style="margin: 0; font-weight: 500;">
{@cancellation_reason}
</p>
</div>
<p style="color: #718096; font-size: 14px; font-style: italic;">
This is an automated notification. No action is required.
</p>

View file

@ -1,18 +1,26 @@
<h1 class="text-orange">🎉 Booking Confirmed!</h1>
<h1 class="text-orange">🎉 Booking Request Approved!</h1>
<p>Hello <strong><%= @customer_name %></strong>,</p>
<p>Thank you for choosing Spazio Solazzo! Your booking has been successfully confirmed.</p>
<p>
Great news! Your booking request has been approved. We look forward to seeing you at Spazio Solazzo!
</p>
<.details_list>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
<.detail_item label="Space">{@space_name}</.detail_item>
<.detail_item label="Email">{@customer_email}</.detail_item>
<.detail_item label="Phone">{@customer_phone || "N/A"}</.detail_item>
<.detail_item label="Note">{@customer_comment || "N/A"}</.detail_item>
</.details_list>
<div style="background-color: #f0fdf4; border-left: 4px solid #22c55e; padding: 15px; margin: 20px 0; color: #2d3748;">
<p style="margin: 0; font-weight: 500;">
✅ Your booking is confirmed! Please arrive on time.
</p>
</div>
<p class="text-center">
If you need to manage or cancel this booking, please use the link below:
If you need to cancel this booking, please use the link below:
</p>
<.email_button href={@cancel_url} variant={:danger}>

View file

@ -0,0 +1,37 @@
<h1 style="color: #dc3545;">❌ Booking Request Not Approved</h1>
<p>Hello <strong><%= @customer_name %></strong>,</p>
<p>We regret to inform you that your booking request could not be approved at this time.</p>
<.details_list>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
<.detail_item label="Space">{@space_name}</.detail_item>
</.details_list>
<div style="background-color: #fff5f5; border-left: 4px solid #dc3545; padding: 15px; margin: 20px 0; color: #2d3748;">
<h3 style="color: #dc3545; margin-top: 0; font-size: 16px;">Reason for Rejection:</h3>
<p style="margin: 0; font-weight: 500;">
{@rejection_reason}
</p>
</div>
<p>
We apologize for any inconvenience. If you have any questions or would like to discuss alternative options, please don't hesitate to contact us.
</p>
<hr class="divider" />
<div style="background-color: #f8fafc; border-radius: 8px; padding: 20px; text-align: center; margin-top: 30px;">
<h3 style="color: #2d3748; margin-top: 0;">Get in Touch</h3>
<p style="color: #4a5568; font-size: 14px; margin-bottom: 15px;">
Our Front Office is here to help you find the best solution.
</p>
<a
href={"tel:#{@front_office_phone_number}"}
style="display: inline-block; background-color: #edf2f7; color: #2d3748; padding: 10px 20px; border-radius: 50px; text-decoration: none; font-weight: bold; font-size: 18px; border: 1px solid #cbd5e0;"
>
📞 {@front_office_phone_number}
</a>
</div>

View file

@ -0,0 +1,46 @@
<h1 class="text-orange">✅ Request Received!</h1>
<p>Hello <strong><%= @customer_name %></strong>,</p>
<p>
Thank you for choosing Spazio Solazzo! Your booking request has been received and is pending approval.
</p>
<.details_list>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
<.detail_item label="Space">{@space_name}</.detail_item>
<.detail_item label="Email">{@customer_email}</.detail_item>
<.detail_item label="Phone">{@customer_phone || "N/A"}</.detail_item>
<.detail_item label="Note">{@customer_comment || "N/A"}</.detail_item>
</.details_list>
<div style="background-color: #fffaf0; border-left: 4px solid #ed8936; padding: 15px; margin: 20px 0; color: #2d3748;">
<p style="margin: 0; font-weight: 500;">
⏳ Your request is pending approval. You will receive an email once an administrator reviews your request.
</p>
</div>
<p class="text-center">
If you need to cancel this request, please use the link below:
</p>
<.email_button href={@cancel_url} variant={:danger}>
Cancel Request
</.email_button>
<hr class="divider" />
<div style="background-color: #f8fafc; border-radius: 8px; padding: 20px; text-align: center; margin-top: 30px;">
<h3 style="color: #2d3748; margin-top: 0;">Need Help?</h3>
<p style="color: #4a5568; font-size: 14px; margin-bottom: 15px;">
Do you have questions or need to update your request details? <br />
Our Front Office is available to assist you at any time.
</p>
<a
href={"tel:#{@front_office_phone_number}"}
style="display: inline-block; background-color: #edf2f7; color: #2d3748; padding: 10px 20px; border-radius: 50px; text-decoration: none; font-weight: bold; font-size: 18px; border: 1px solid #cbd5e0;"
>
📞 {@front_office_phone_number}
</a>
</div>

View file

@ -0,0 +1,248 @@
defmodule SpazioSolazzoWeb.Admin.AdminCalendarComponent do
@moduledoc """
Admin calendar for managing bookings, visualizing capacity, and selecting date ranges.
"""
use SpazioSolazzoWeb, :live_component
alias SpazioSolazzo.CalendarExt
@doc "Resets the calendar selection state."
def reset(id) do
send_update(__MODULE__, id: id, reset: true)
end
def update(%{reset: true}, socket) do
socket =
socket
|> assign(start_date: nil)
|> assign(end_date: nil)
|> assign(selected_date: nil)
{:ok, socket}
end
def update(assigns, socket) do
first_day =
assigns[:first_day_of_month] ||
socket.assigns[:first_day_of_month] ||
Date.utc_today() |> Date.beginning_of_month()
grid = CalendarExt.build_calendar_grid(first_day)
socket =
socket
|> assign(assigns)
|> assign_new(:booking_counts, fn -> %{} end)
|> assign_new(:multi_day_mode, fn -> false end)
|> assign_new(:start_date, fn -> nil end)
|> assign_new(:end_date, fn -> nil end)
|> assign_new(:selected_date, fn -> nil end)
|> assign(first_day_of_month: first_day)
|> assign(grid: grid)
{:ok, socket}
end
def handle_event("prev_month", _, socket) do
new_date = Date.shift(socket.assigns.first_day_of_month, month: -1)
grid = CalendarExt.build_calendar_grid(new_date)
socket =
socket
|> assign(first_day_of_month: new_date)
|> assign(grid: grid)
send(self(), {:change_month, new_date})
{:noreply, socket}
end
def handle_event("next_month", _, socket) do
new_date = Date.shift(socket.assigns.first_day_of_month, month: 1)
grid = CalendarExt.build_calendar_grid(new_date)
socket =
socket
|> assign(first_day_of_month: new_date)
|> assign(grid: grid)
send(self(), {:change_month, new_date})
{:noreply, socket}
end
def handle_event("toggle_multi_day", _, socket) do
new_mode = !socket.assigns.multi_day_mode
send(self(), {:multi_day_mode_toggle, new_mode})
{:noreply,
assign(socket,
multi_day_mode: new_mode,
start_date: nil,
end_date: nil,
selected_date: nil
)}
end
def handle_event("select_date", %{"date" => d}, socket) do
date = Date.from_iso8601!(d)
if socket.assigns.multi_day_mode do
handle_multi_select(socket, date)
else
send(self(), {:date_selected, date, date})
{:noreply, assign(socket, selected_date: date, start_date: nil, end_date: nil)}
end
end
defp handle_multi_select(
%{assigns: %{start_date: start_date, end_date: end_date}} = socket,
date
) do
cond do
is_nil(start_date) ->
# Start Selection
{:noreply, assign(socket, start_date: date)}
is_nil(end_date) ->
# End Selection (Order correctly)
{new_start, new_end} =
if Date.compare(date, start_date) == :lt,
do: {date, start_date},
else: {start_date, date}
send(self(), {:date_selected, new_start, new_end})
{:noreply, assign(socket, start_date: new_start, end_date: new_end)}
true ->
# Reset
{:noreply, assign(socket, start_date: date, end_date: nil)}
end
end
def render(assigns) do
~H"""
<div class="flex flex-col gap-4">
<%!-- Toolbar --%>
<div
class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700"
phx-click="toggle_multi_day"
phx-target={@myself}
>
<input
type="checkbox"
checked={@multi_day_mode}
class="checkbox checkbox-primary checkbox-sm pointer-events-none"
/>
<label class="text-sm font-semibold select-none cursor-pointer">
Enable Multi-Day Selection
</label>
</div>
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl p-4 shadow-sm">
<div class="flex items-center justify-between mb-4">
<button phx-click="prev_month" phx-target={@myself} class="btn btn-sm btn-ghost btn-circle">
<.icon name="hero-chevron-left" />
</button>
<h4 class="font-bold text-lg capitalize">
{Calendar.strftime(@first_day_of_month, "%B %Y")}
</h4>
<button phx-click="next_month" phx-target={@myself} class="btn btn-sm btn-ghost btn-circle">
<.icon name="hero-chevron-right" />
</button>
</div>
<div class="grid grid-cols-7 mb-2 text-center text-xs font-bold text-slate-400 uppercase tracking-wider">
<span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span>
</div>
<div class="grid grid-cols-7 gap-1 md:gap-2">
<%= for date <- @grid do %>
<% # Uses the booking_counts passed from parent
count = Map.get(@booking_counts, date, 0)
is_current = date.month == @first_day_of_month.month %>
<div class={[day_classes(date, assigns), !is_current && "opacity-25 grayscale"]}>
<%!-- Header Row: Date & Badge --%>
<div class="flex justify-between items-start">
<span class="text-xs font-bold">{date.day}</span>
<%= if count > 0 and is_current do %>
<.link
navigate={~p"/admin/bookings?date=#{Date.to_string(date)}"}
class="badge badge-info badge-xs text-white font-bold hover:scale-110 transition-transform"
title={"#{count} bookings"}
>
{count}
</.link>
<% end %>
</div>
<%= if @multi_day_mode do %>
{if @start_date == date, do: echo_label("Start")}
{if @end_date == date, do: echo_label("End")}
<% end %>
<%= if Date.compare(date, Date.utc_today()) != :lt do %>
<button
phx-click="select_date"
phx-value-date={date}
phx-target={@myself}
class="absolute inset-0 w-full h-full"
>
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
"""
end
defp day_classes(date, %{
start_date: start_date,
end_date: end_date,
selected_date: selected_date,
multi_day_mode: multi
}) do
is_past = Date.compare(date, Date.utc_today()) == :lt
is_start = start_date == date
is_end = end_date == date
is_sel = selected_date == date
in_range = CalendarExt.date_in_range?(date, start_date, end_date)
base =
"relative aspect-square flex flex-col p-2 transition-all border border-slate-200 dark:border-slate-700 "
cond do
is_past ->
base <> "bg-slate-50 dark:bg-slate-800/50 text-slate-300 cursor-not-allowed"
is_start ->
base <> "bg-primary text-white rounded-l-lg z-10 shadow-md"
is_end ->
base <> "bg-primary text-white rounded-r-lg z-10 shadow-md"
in_range && multi ->
base <> "bg-primary/20 text-slate-900 dark:text-white"
is_sel ->
base <> "bg-primary text-white rounded-lg shadow-md"
true ->
base <> "bg-white dark:bg-slate-800 hover:bg-slate-50 text-slate-700 dark:text-slate-200"
end
end
defp echo_label(text) do
assigns = %{text: text}
~H"""
<span class="mt-auto text-[10px] uppercase font-black tracking-tighter">{@text}</span>
"""
end
end

View file

@ -0,0 +1,251 @@
defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
@moduledoc """
Admin booking management tool for reviewing and managing all booking requests.
Refactored to use URL as the Single Source of Truth.
"""
use SpazioSolazzoWeb, :live_view
import SpazioSolazzoWeb.AdminBookingManagementComponents
alias SpazioSolazzo.BookingSystem
@pending_limit 10
@history_limit 10
def mount(_params, _session, socket) do
{:ok, spaces} = Ash.read(SpazioSolazzo.BookingSystem.Space)
if connected?(socket) do
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:approved")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:rejected")
end
{:ok,
assign(socket,
spaces: spaces,
expanded_booking_ids: MapSet.new(),
show_reject_modal: false,
rejecting_booking_id: nil,
rejection_reason: ""
)}
end
def handle_params(params, _uri, socket) do
# Destructure params with defaults. This ensures all keys exist.
%{
"space" => space_slug,
"email" => email,
"date" => date_str,
"pending_page" => pending_str,
"history_page" => history_str
} =
Map.merge(
%{
"space" => nil,
"email" => "",
"date" => nil,
"pending_page" => "1",
"history_page" => "1"
},
params
)
socket =
socket
|> assign(
filter_space: parse_string(space_slug),
filter_email: email,
filter_date: parse_date(date_str),
pending_page_number: parse_page(pending_str),
history_page_number: parse_page(history_str)
)
|> fetch_bookings()
{:noreply, socket}
end
def handle_event(
"filter_bookings",
%{"date" => date, "space" => space, "email" => email},
socket
) do
params = %{
"date" => date,
"space" => space,
"email" => email,
# Reset pagination to page 1 when filtering
"pending_page" => "1",
"history_page" => "1"
}
{:noreply, push_patch(socket, to: build_filter_path(socket, params), replace: true)}
end
def handle_event("clear_filters", _, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/bookings", replace: true)}
end
def handle_event("pending_page_change", %{"page" => page}, socket) do
{:noreply, push_patch(socket, to: build_filter_path(socket, %{"pending_page" => page}))}
end
def handle_event("history_page_change", %{"page" => page}, socket) do
{:noreply, push_patch(socket, to: build_filter_path(socket, %{"history_page" => page}))}
end
def handle_event("toggle_expand", %{"booking_id" => booking_id}, socket) do
expanded =
if MapSet.member?(socket.assigns.expanded_booking_ids, booking_id) do
MapSet.delete(socket.assigns.expanded_booking_ids, booking_id)
else
MapSet.put(socket.assigns.expanded_booking_ids, booking_id)
end
{:noreply, assign(socket, expanded_booking_ids: expanded)}
end
def handle_event("approve_booking", %{"booking_id" => booking_id}, socket) do
case Ash.get(BookingSystem.Booking, booking_id) do
{:ok, booking} ->
BookingSystem.approve_booking(booking)
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Booking not found")}
end
end
def handle_event("show_reject_modal", %{"booking_id" => booking_id}, socket) do
{:noreply,
assign(socket,
show_reject_modal: true,
rejecting_booking_id: booking_id,
rejection_reason: ""
)}
end
def handle_event("hide_reject_modal", _, socket) do
{:noreply, assign(socket, show_reject_modal: false, rejecting_booking_id: nil)}
end
def handle_event("stop_propagation", _, socket), do: {:noreply, socket}
def handle_event("update_rejection_reason", %{"reason" => reason}, socket) do
{:noreply, assign(socket, rejection_reason: reason)}
end
def handle_event("confirm_reject", _, socket) do
%{rejecting_booking_id: id, rejection_reason: reason} = socket.assigns
if String.trim(reason) == "" do
{:noreply, put_flash(socket, :error, "Please provide a rejection reason")}
else
case Ash.get(BookingSystem.Booking, id) do
{:ok, booking} ->
BookingSystem.reject_booking(booking, reason)
socket =
socket
|> assign(show_reject_modal: false, rejecting_booking_id: nil)
|> put_flash(:info, "Booking rejected")
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Booking not found")}
end
end
end
def handle_info(%{topic: "booking:" <> _}, socket) do
{:noreply, fetch_bookings(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
defp fetch_bookings(socket) do
%{
filter_space: space_slug,
filter_email: email,
filter_date: date,
pending_page_number: pending_page,
history_page_number: history_page,
spaces: spaces
} = socket.assigns
space_id =
spaces
|> Enum.find(%{}, fn s -> s.slug == space_slug end)
|> Map.get(:id, nil)
query_email = parse_string(email)
pending_offset = (pending_page - 1) * @pending_limit
history_offset = (history_page - 1) * @history_limit
{:ok, pending_data} =
BookingSystem.read_pending_bookings(space_id, query_email, date,
page: [limit: @pending_limit, offset: pending_offset, count: true],
load: [:space, :user]
)
{:ok, history_data} =
BookingSystem.read_booking_history(space_id, query_email, date,
page: [limit: @history_limit, offset: history_offset, count: true],
load: [:space, :user]
)
assign(socket, pending_page: pending_data, history_page: history_data)
end
defp build_filter_path(socket, overrides) do
current = %{
"space" => socket.assigns.filter_space,
"email" => socket.assigns.filter_email,
"date" => socket.assigns.filter_date,
"pending_page" => socket.assigns.pending_page_number,
"history_page" => socket.assigns.history_page_number
}
# Merge overrides -> Filter empty values -> Encode
params =
current
|> Map.merge(overrides)
|> Enum.reduce(%{}, fn
{_k, nil}, acc -> acc
{_k, ""}, acc -> acc
{"date", %Date{} = d}, acc -> Map.put(acc, "date", Date.to_iso8601(d))
{k, v}, acc -> Map.put(acc, k, v)
end)
~p"/admin/bookings"
|> URI.parse()
|> URI.append_query(URI.encode_query(params))
|> URI.to_string()
end
defp parse_page(nil), do: 1
defp parse_page(""), do: 1
defp parse_page(value) do
case Integer.parse(value) do
{n, _} when n > 0 -> n
_ -> 1
end
end
defp parse_date(nil), do: nil
defp parse_date(""), do: nil
defp parse_date(value) do
case Date.from_iso8601(value) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_string(nil), do: nil
defp parse_string(""), do: nil
defp parse_string(value), do: value
end

View file

@ -0,0 +1,196 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<main class="flex-grow px-4 py-8 md:px-8">
<div class="max-w-6xl mx-auto flex flex-col gap-8">
<.back_to_link
navigate={~p"/admin/dashboard"}
value="Back to Dashboard"
/>
<%!-- Title and stats --%>
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-slate-900 dark:text-white tracking-tight text-3xl md:text-4xl font-extrabold">
Manage Bookings
</h1>
<p class="text-slate-600 dark:text-slate-400 text-base font-normal max-w-2xl">
Review reservations and booking history. Pending requests require approval.
</p>
</div>
<div class="flex gap-4">
<div class="bg-white dark:bg-slate-800 px-5 py-3 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm flex flex-col items-start min-w-[140px]">
<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">{@pending_page.count}</span>
</div>
</div>
</div>
<%!-- Filters --%>
<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">
<label class="text-sm font-semibold text-slate-900 dark:text-slate-200 ml-1">
Customer Email
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
<.icon name="hero-magnifying-glass" class="w-5 h-5" />
</span>
<input
name="email"
value={@filter_email}
class="w-full h-12 pl-10 pr-4 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-400"
placeholder="Search by email..."
type="text"
/>
</div>
</div>
<div class="col-span-1 md:col-span-3 flex flex-col gap-1.5">
<label class="text-sm font-semibold text-slate-900 dark:text-slate-200 ml-1">
Space
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<.icon name="hero-map-pin" class="w-5 h-5" />
</span>
<select
name="space"
class="w-full h-12 pl-10 pr-10 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none appearance-none cursor-pointer"
>
<option value="">All Spaces</option>
<%= for space <- @spaces do %>
<option value={space.slug} selected={@filter_space == space.slug}>
{space.name}
</option>
<% end %>
</select>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<.icon name="hero-chevron-down" class="w-5 h-5" />
</span>
</div>
</div>
<div class="col-span-1 md:col-span-3 flex flex-col gap-1.5">
<label class="text-sm font-semibold text-slate-900 dark:text-slate-200 ml-1">
Date
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<.icon name="hero-calendar" class="w-5 h-5" />
</span>
<input
name="date"
value={if @filter_date, do: Date.to_iso8601(@filter_date), else: ""}
class="w-full h-12 pl-10 pr-4 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none cursor-pointer"
type="date"
/>
</div>
</div>
<div class="col-span-1 md:col-span-2">
<button
type="button"
phx-click="clear_filters"
class="w-full h-12 bg-slate-900 dark:bg-white hover:bg-slate-800 dark:hover:bg-slate-100 text-white dark:text-slate-900 font-bold rounded-xl transition-colors flex items-center justify-center gap-2 cursor-pointer"
>
<span>Clear Filters</span>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
</form>
</div>
<%= 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>
<%!-- Reject Modal --%>
<%= if @show_reject_modal do %>
<div
class="fixed inset-0 bg-slate-900/20 backdrop-blur-sm flex items-center justify-center p-4 z-50"
phx-click="hide_reject_modal"
>
<div
class="bg-white dark:bg-slate-800 rounded-3xl border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none max-w-md w-full p-6"
phx-click="stop_propagation"
>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-3">Reject Booking</h3>
<p class="text-slate-600 dark:text-slate-400 mb-4 text-sm">
Provide a reason for rejecting this booking. The customer will receive this in their email.
</p>
<form phx-submit="confirm_reject">
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-900 dark:text-white mb-2">
Rejection Reason <span class="text-red-500">*</span>
</label>
<textarea
phx-change="update_rejection_reason"
name="reason"
rows="4"
required
placeholder="e.g., Space under maintenance, Fully booked, etc."
class="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-primary"
>{@rejection_reason}</textarea>
</div>
<div class="flex gap-2">
<button
type="button"
phx-click="hide_reject_modal"
class="flex-1 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 px-4 py-2 rounded-lg font-medium hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors text-sm"
>
Cancel
</button>
<button
type="submit"
class="flex-1 bg-red-500 text-white px-4 py-2 rounded-lg font-medium hover:bg-red-600 transition-colors text-sm"
>
Reject
</button>
</div>
</form>
</div>
</div>
<% end %>
</Layouts.app>

View file

@ -1,21 +1,13 @@
defmodule SpazioSolazzoWeb.Admin.DashboardLive do
@moduledoc """
Admin dashboard home page. Lists the available tools that admins have
Admin dashboard home page showing available management tools.
"""
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.AdminComponents
import SpazioSolazzoWeb.AdminDashboardComponents
def mount(_params, _session, socket) do
{:ok, coworking_space} = BookingSystem.get_space_by_slug("coworking", not_found_error?: false)
{:ok, meeting_space} = BookingSystem.get_space_by_slug("meeting", not_found_error?: false)
{:ok,
assign(socket,
coworking_space: coworking_space,
meeting_space: meeting_space
)}
{:ok, socket}
end
end

View file

@ -1,28 +1,32 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="mx-auto max-w-[1200px] px-6 py-12">
<.back_to_link
navigate={~p"/"}
value="Back to Home"
/>
<main class="flex-grow px-4 py-8 md:px-8">
<div class="max-w-6xl mx-auto">
<div class="mb-12 text-center">
<h1 class="text-4xl md:text-5xl font-black text-slate-900 dark:text-white tracking-tight mb-4">
Admin Dashboard
</h1>
<p class="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
Welcome to Spazio Solazzo management center.
</p>
</div>
<h1 class="text-3xl text-base-content font-bold mb-8">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<%= if @meeting_space do %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<.tool_card
title={@meeting_space.name}
description="Create walk-in bookings for the space"
icon="hero-user-group"
title="Booking Management"
description="Review and manage pending booking requests. Approve or reject reservations and view booking history."
color={:primary}
icon="hero-clipboard-document-list"
navigate={~p"/admin/bookings"}
/>
<% end %>
<%= if @coworking_space do %>
<.tool_card
title={@coworking_space.name}
description="Create walk-in bookings for the space"
icon="hero-user-group"
title="Arcipelago Walk-in Booking"
description="Create instant bookings for walk-in customers at the coworking space."
color={:secondary}
icon="hero-building-office-2"
navigate={~p"/admin/walk-in"}
/>
<% end %>
</div>
</div>
</div>
</main>
</Layouts.app>

View file

@ -0,0 +1,180 @@
defmodule SpazioSolazzoWeb.Admin.WalkInLive do
@moduledoc """
Admin walk-in booking tool for the coworking space.
"""
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("coworking")
today = Date.utc_today()
first_day = Date.beginning_of_month(today)
booking_counts = fetch_booking_counts(space.id, first_day)
if connected?(socket) do
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:approved")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:rejected")
end
{:ok,
assign(socket,
space: space,
first_day_of_month: first_day,
booking_counts: booking_counts,
start_date: nil,
end_date: nil,
start_time: ~T[09:00:00],
end_time: ~T[18:00:00],
multi_day_mode: false,
customer_details_form: customer_details_form()
)}
end
def handle_event("update_start_time", %{"value" => time_str}, socket) do
case Time.from_iso8601(time_str <> ":00") do
{:ok, time} -> {:noreply, assign(socket, start_time: time)}
_ -> {:noreply, socket}
end
end
def handle_event("update_end_time", %{"value" => time_str}, socket) do
case Time.from_iso8601(time_str <> ":00") do
{:ok, time} -> {:noreply, assign(socket, end_time: time)}
_ -> {:noreply, socket}
end
end
def handle_event("validate_customer_details", form, socket) do
{:noreply, assign(socket, customer_details_form: to_form(form))}
end
def handle_event("create_booking", _, %{assigns: %{start_date: s, end_date: e}} = socket)
when is_nil(s) or is_nil(e) do
{:noreply, put_flash(socket, :error, "Please fill in all required fields and select a date")}
end
def handle_event("create_booking", form, socket) do
case parse_submitted_form(form) do
{:error, error} -> {:noreply, put_flash(socket, :error, error)}
{:ok, form} -> create_walk_in(form, socket)
end
end
def handle_info({:change_month, new_date}, socket) do
booking_counts = fetch_booking_counts(socket.assigns.space.id, new_date)
{:noreply, assign(socket, first_day_of_month: new_date, booking_counts: booking_counts)}
end
def handle_info({:date_selected, start_date, end_date}, socket) do
{:noreply, assign(socket, start_date: start_date, end_date: end_date)}
end
def handle_info({:multi_day_mode_toggle, mode}, socket) do
{:noreply, assign(socket, multi_day_mode: mode)}
end
def handle_info(%{topic: "booking:" <> _}, socket) do
booking_counts =
fetch_booking_counts(socket.assigns.space.id, socket.assigns.first_day_of_month)
{:noreply, assign(socket, booking_counts: booking_counts)}
end
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp fetch_booking_counts(space_id, date) do
start_dt = DateTime.new!(Date.beginning_of_month(date), ~T[00:00:00])
end_dt = DateTime.new!(Date.end_of_month(date), ~T[23:59:59])
{:ok, bookings} =
BookingSystem.search_bookings(
space_id,
start_dt,
end_dt,
[:accepted],
[:start_datetime, :end_datetime]
)
Enum.reduce(bookings, %{}, fn booking, acc ->
range =
Date.range(
DateTime.to_date(booking.start_datetime),
DateTime.to_date(booking.end_datetime)
)
Enum.reduce(range, acc, fn d, count_acc ->
Map.update(count_acc, d, 1, &(&1 + 1))
end)
end)
end
defp create_walk_in(form, socket) do
%{
start_date: start_date,
end_date: end_date,
start_time: start_time,
end_time: end_time,
space: space
} = socket.assigns
start_datetime = DateTime.new!(start_date, start_time, "Etc/UTC")
end_datetime = DateTime.new!(end_date, end_time, "Etc/UTC")
case BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
form.customer_name,
form.customer_email,
form.customer_phone
) do
{:ok, _booking} ->
SpazioSolazzoWeb.Admin.AdminCalendarComponent.reset("walk-in-calendar")
{:noreply,
socket
|> assign(
customer_details_form: customer_details_form(),
start_date: nil,
end_date: nil
)
|> put_flash(:info, "Walk-in booking created successfully")}
{:error, error} ->
{:noreply, put_flash(socket, :error, "Failed to create walk-in: #{inspect(error)}")}
end
end
defp parse_submitted_form(%{
"customer_name" => name,
"customer_email" => email,
"customer_phone" => phone
}) do
name = String.trim(name)
email = String.trim(email)
phone = String.trim(phone)
if name == "" || email == "" do
{:error, "Please fill in all required fields and select a date"}
else
{:ok, %{customer_name: name, customer_email: email, customer_phone: phone}}
end
end
defp days_selected(nil, nil), do: 0
defp days_selected(start_date, nil) when not is_nil(start_date), do: 1
defp days_selected(start_date, end_date) when not is_nil(start_date) and not is_nil(end_date),
do: Date.diff(end_date, start_date) + 1
defp days_selected(_, _), do: 0
defp customer_details_form(),
do: to_form(%{"customer_name" => "", "customer_email" => "", "customer_phone" => ""})
end

View file

@ -0,0 +1,245 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<main class="flex-1">
<section class="mx-auto max-w-[1000px] px-6 py-10">
<.back_to_link
navigate={~p"/admin/dashboard"}
value="Back to Dashboard"
/>
<%!-- Title --%>
<div class="mb-10 flex flex-col items-center md:items-start text-center md:text-left">
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white tracking-tight mb-3">
New Arcipelago Walk-in Booking
</h1>
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-lg">
Create a walk-in booking for the Arcipelago space.
</p>
</div>
<div class="space-y-8">
<%!-- Date & Time --%>
<article class="bg-white dark:bg-slate-800 rounded-3xl p-6 md:p-8 border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none transition-all hover:border-primary/50 dark:hover:border-primary/50 group">
<header class="flex flex-col md:flex-row md:items-center gap-4 mb-8 pb-6 border-b border-slate-100 dark:border-slate-700/50">
<div class="size-12 rounded-2xl bg-primary text-white flex items-center justify-center text-xl font-bold shadow-lg shadow-primary/30 group-hover:scale-110 transition-transform duration-300">
1
</div>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white">
Select Date & Time
</h2>
<p class="text-sm font-medium text-slate-500 dark:text-slate-400 mt-1">
Check availability on the calendar and set your booking range.
</p>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<%!-- Calendar --%>
<div class="lg:col-span-2">
<.live_component
module={SpazioSolazzoWeb.Admin.AdminCalendarComponent}
id="walk-in-calendar"
space_id={@space.id}
booking_counts={@booking_counts}
/>
</div>
<%!-- Selected dates and time inputs --%>
<div class="flex flex-col gap-6 lg:border-l border-slate-100 dark:border-slate-700 lg:pl-8">
<%!-- Selected interval --%>
<div>
<h3 class="text-sm font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-4">
Selected Interval
</h3>
<div class="p-4 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500 dark:text-slate-400">
Start Date
</span>
<span class="text-sm font-bold text-slate-900 dark:text-white">
<%= if @start_date do %>
{Calendar.strftime(@start_date, "%b %d, %Y")}
<% else %>
<span class="text-slate-400">Not selected</span>
<% end %>
</span>
</div>
<div class="w-full h-px bg-slate-200 dark:bg-slate-700"></div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500 dark:text-slate-400">
End Date
</span>
<span class="text-sm font-bold text-slate-900 dark:text-white">
<%= if @end_date do %>
{Calendar.strftime(@end_date, "%b %d, %Y")}
<% else %>
<span class="text-slate-400">Not selected</span>
<% end %>
</span>
</div>
<div class="mt-2 text-xs font-medium text-primary dark:text-secondary flex items-center gap-1">
<.icon
name={
if @multi_day_mode && @end_date,
do: "hero-calendar-days",
else: "hero-calendar"
}
class="w-4 h-4"
/>
<span>
<%= if @multi_day_mode && @start_date && @end_date do %>
{days_selected(@start_date, @end_date)} Days total
<% else %>
Single Day
<% end %>
</span>
</div>
</div>
</div>
<%!-- Daily schedule --%>
<div>
<h3 class="text-sm font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-4">
Daily Schedule
</h3>
<%= if @multi_day_mode do %>
<%!-- Multi-day mode: Show info card --%>
<div class="w-full bg-slate-50 dark:bg-slate-800/40 border border-slate-200 dark:border-slate-700 rounded-xl p-5 flex flex-col items-center text-center">
<div class="size-10 bg-primary/10 dark:bg-primary/20 text-primary rounded-full flex items-center justify-center mb-3">
<.icon name="hero-calendar-days" class="w-5 h-5" />
</div>
<p class="text-sm font-bold text-slate-900 dark:text-slate-100">
Full day booking applied for the selected range
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">
Start and end time inputs are disabled for multiday selections.
</p>
</div>
<% else %>
<%!-- Single-day mode: Show time inputs --%>
<div class="p-4 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 flex flex-col gap-4">
<div class="relative">
<label
class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-2"
for="start-time"
>
Start Time
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400 pointer-events-none">
<.icon name="hero-clock" class="w-4 h-4" />
</span>
<input
id="start-time"
type="time"
value={Calendar.strftime(@start_time, "%H:%M")}
phx-change="update_start_time"
class="w-full bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all shadow-sm"
/>
</div>
</div>
<div class="relative">
<label
class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-2"
for="end-time"
>
End Time
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400 pointer-events-none">
<.icon name="hero-clock" class="w-4 h-4" />
</span>
<input
id="end-time"
type="time"
value={Calendar.strftime(@end_time, "%H:%M")}
phx-change="update_end_time"
class="w-full bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all shadow-sm"
/>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
</article>
<%!-- Customer Details --%>
<article class="bg-white dark:bg-slate-800 rounded-3xl p-6 md:p-8 border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none transition-all hover:border-primary/50 dark:hover:border-primary/50 group">
<header class="flex flex-col md:flex-row md:items-center gap-4 mb-8 pb-6 border-b border-slate-100 dark:border-slate-700/50">
<div class="size-12 rounded-2xl bg-primary text-white flex items-center justify-center text-xl font-bold shadow-lg shadow-primary/30 group-hover:scale-110 transition-transform duration-300">
2
</div>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white">
Customer Details
</h2>
<p class="text-sm font-medium text-slate-500 dark:text-slate-400 mt-1">
Enter the customer's contact information to complete the booking.
</p>
</div>
</header>
<.form
for={@customer_details_form}
id="customer-details-form"
phx-change="validate_customer_details"
phx-submit="create_booking"
class="space-y-4 max-w-2xl"
>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400">
<.icon name="hero-user" class="w-5 h-5" />
</span>
<.input
field={@customer_details_form["customer_name"]}
class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
placeholder="Customer Name"
type="text"
required
/>
</div>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400">
<.icon name="hero-envelope" class="w-5 h-5" />
</span>
<.input
field={@customer_details_form["customer_email"]}
class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
placeholder="customer@example.com"
type="email"
required
/>
</div>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400">
<.icon name="hero-phone" class="w-5 h-5" />
</span>
<.input
field={@customer_details_form["customer_phone"]}
class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
placeholder="Customer Phone Number (Optional)"
type="tel"
/>
</div>
</.form>
</article>
<div class="flex justify-center pt-4">
<.button
type="submit"
form="customer-details-form"
class="w-full sm:w-auto flex items-center justify-center gap-2 overflow-hidden rounded-xl h-12 px-10 bg-primary hover:bg-primary/90 transition-colors text-white text-base font-bold shadow-lg shadow-primary/30 cursor-pointer"
>
<span>Create Booking</span>
</.button>
</div>
</div>
</section>
</main>
</Layouts.app>

View file

@ -1,78 +0,0 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<section class="mx-auto max-w-[1200px] px-6 py-10">
<div class="mb-4">
<.back_to_link
navigate={"/#{@space.slug}"}
value={"Back to #{@space.name}"}
/>
</div>
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl lg:text-6xl font-black text-base-content tracking-tight mb-4">
{@asset.name}
</h1>
<p class="text-lg text-neutral max-w-2xl mx-auto">
{@space.name} - Flexible booking options available
</p>
</div>
<div class="max-w-4xl mx-auto bg-base-100 rounded-3xl p-8 md:p-12 border border-base-200 shadow-xl">
<h2 class="text-2xl font-bold text-base-content mb-8">
Available Time Slots
</h2>
<div class="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<.live_component
module={SpazioSolazzoWeb.CalendarLiveComponent}
id="booking-calendar"
selected_date={@selected_date}
/>
<div class="time-slots-wrapper">
<p class="mb-4 text-neutral">
Selected day:
<span class="font-bold text-base-content">
{SpazioSolazzo.CalendarExt.format_date(@selected_date)}
</span>
</p>
<div class="max-h-80 overflow-y-auto pr-4 space-y-3">
<%= if @time_slots == [] do %>
<div class="text-center py-8 text-neutral">
No time slots available for this date
</div>
<% else %>
<%= for time_slot <- @time_slots do %>
<% booked = slot_booked?(time_slot.id, @bookings) %>
<.time_slot booked={booked} time_slot={time_slot} />
<% end %>
<% end %>
</div>
</div>
</div>
<div class="mt-8 pt-6 border-t border-base-200 text-center">
<p class="text-base font-medium text-secondary flex items-center justify-center gap-2">
<.icon name="hero-credit-card" class="w-5 h-5" /> Payment due upon arrival.
</p>
</div>
</div>
</section>
<.live_component
module={SpazioSolazzoWeb.BookingFormLiveComponent}
id="booking-modal"
show={@show_booking_modal}
selected_time_slot={@selected_time_slot}
asset={@asset}
selected_date={@selected_date}
current_user={@current_user}
on_cancel={JS.push("cancel_booking")}
/>
<.booking_confirmation_modal
id="success-modal"
show={@show_success_modal}
on_close={JS.push("close_success_modal")}
/>
</Layouts.app>

View file

@ -0,0 +1,63 @@
defmodule SpazioSolazzoWeb.BookingCancellationLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking.Token
def mount(%{"token" => token}, _session, socket) do
case Token.verify(token) do
{:ok, %{booking_id: booking_id, action: :cancel}} ->
handle_booking(booking_id, socket)
{:error, _} ->
{:ok,
socket
|> put_flash(:error, "Invalid or expired cancellation link")
|> push_navigate(to: "/")}
end
end
defp handle_booking(booking_id, socket) do
case Ash.get(SpazioSolazzo.BookingSystem.Booking, booking_id, load: [:space]) do
{:ok, %{state: state} = booking} when state in [:requested, :accepted] ->
{:ok,
assign(socket,
booking: booking,
cancellation_reason: "",
show_success: false
)}
{:ok, _} ->
{:ok,
socket
|> put_flash(:error, "This booking has already been cancelled or completed")
|> push_navigate(to: "/")}
{:error, _} ->
{:ok,
socket
|> put_flash(:error, "Booking not found")
|> push_navigate(to: "/")}
end
end
def handle_event("validate", %{"reason" => reason}, socket) do
{:noreply, assign(socket, cancellation_reason: reason)}
end
def handle_event("cancel_booking", %{"reason" => reason}, socket) do
if String.trim(reason) == "" do
{:noreply, put_flash(socket, :error, "Please provide a reason for cancellation")}
else
booking = socket.assigns.booking
case BookingSystem.cancel_booking(booking, reason) do
{:ok, _cancelled_booking} ->
{:noreply, assign(socket, show_success: true)}
{:error, _error} ->
{:noreply, put_flash(socket, :error, "Failed to cancel booking. Please try again.")}
end
end
end
end

View file

@ -0,0 +1,97 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto">
<%= if @show_success do %>
<div class="bg-white rounded-2xl shadow-xl p-8 text-center">
<div class="mb-6">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-4">
<svg
class="h-10 w-10 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 class="text-3xl font-bold text-slate-900 mb-2">Booking Cancelled</h1>
<p class="text-slate-600">
Your booking has been successfully cancelled. The administrator has been notified.
</p>
</div>
<.back_to_link
navigate={~p"/"}
value="Return to Home"
/>
</div>
<% else %>
<div class="bg-white rounded-2xl shadow-xl p-8">
<h1 class="text-3xl font-bold text-slate-900 mb-6">Cancel Booking</h1>
<div class="mb-8 p-6 bg-slate-50 rounded-xl">
<h2 class="text-lg font-semibold text-slate-900 mb-4">Booking Details</h2>
<div class="space-y-2 text-slate-700">
<p>
<strong>Space:</strong>
{@booking.space.name}
</p>
<p>
<strong>Date:</strong>
{Calendar.strftime(@booking.date, "%A, %B %d, %Y")}
</p>
<p>
<strong>Time:</strong>
{@booking.start_time} - {@booking.end_time}
</p>
<p>
<strong>Customer:</strong>
{@booking.customer_name}
</p>
</div>
</div>
<form phx-submit="cancel_booking" phx-change="validate">
<div class="mb-6">
<label for="reason" class="block text-sm font-semibold text-slate-900 mb-2">
Cancellation Reason <span class="text-red-500">*</span>
</label>
<textarea
id="reason"
name="reason"
rows="4"
required
value={@cancellation_reason}
placeholder="Please let us know why you're cancelling..."
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
></textarea>
<p class="mt-2 text-sm text-slate-500">
This helps us improve our service and understand your needs better.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<button
type="submit"
class="flex-1 bg-red-500 text-white px-6 py-3 rounded-xl font-semibold hover:bg-red-600 transition-colors"
>
Confirm Cancellation
</button>
<.link
navigate="/"
class="flex-1 bg-slate-200 text-slate-700 px-6 py-3 rounded-xl font-semibold hover:bg-slate-300 transition-colors text-center"
>
Keep Booking
</.link>
</div>
</form>
</div>
<% end %>
</div>
</div>
</Layouts.app>

View file

@ -8,9 +8,11 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
alias SpazioSolazzo.CalendarExt
def update(assigns, socket) do
current_user = assigns.current_user
initial_data = %{
"customer_name" => assigns.current_user.name,
"customer_phone" => assigns.current_user.phone_number || "",
"customer_name" => current_user.name,
"customer_phone" => current_user.phone_number || "",
"customer_comment" => ""
}
@ -28,9 +30,9 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
def handle_event("submit_booking", params, socket) do
booking_data = %{
customer_name: params["customer_name"] || "",
customer_phone: params["customer_phone"] || "",
customer_comment: params["customer_comment"] || ""
customer_name: String.trim(params["customer_name"] || ""),
customer_phone: String.trim(params["customer_phone"] || ""),
customer_comment: String.trim(params["customer_comment"] || "")
}
send(self(), {:create_booking, booking_data})
@ -44,13 +46,33 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
<:title>Complete Your Booking</:title>
<:subtitle>
<%= if @selected_time_slot do %>
{@asset.name} | {CalendarExt.format_time_range(@selected_time_slot)} on {CalendarExt.format_date(
{@space.name} | {CalendarExt.format_time_range(@selected_time_slot)} on {CalendarExt.format_date(
@selected_date
)}
<% end %>
</:subtitle>
<div>
<%= if @slot_availability == :over_capacity do %>
<div class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 rounded">
<div class="flex gap-3">
<div class="flex-shrink-0">
<.icon
name="hero-exclamation-triangle"
class="size-5 text-yellow-600 dark:text-yellow-400"
/>
</div>
<div>
<p class="text-sm font-semibold text-yellow-800 dark:text-yellow-300">
High Demand Time Slot
</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
This time slot is popular. Your request will be subject to admin approval based on availability.
</p>
</div>
</div>
</div>
<% end %>
<.form
for={@form}
id="booking-form"

View file

@ -1,134 +1,111 @@
defmodule SpazioSolazzoWeb.CalendarLiveComponent do
defmodule SpazioSolazzoWeb.BookingCalendarLiveComponent do
@moduledoc """
LiveView component for rendering booking calendars.
The calendar displayed in the space booking view.
It allows users to select a date in a beautifully-styled calendar grid.
"""
use SpazioSolazzoWeb, :live_component
# There are 7 days displayed in the calendar
@grid_cols 7
# The calendar can show max 6 weeks for one month
@grid_rows 6
alias SpazioSolazzo.CalendarExt
def update(assigns, socket) do
# Initialize navigation date to today's month if not already viewing a month
beginning_of_month =
socket.assigns[:beginning_of_month] ||
Date.utc_today()
|> Date.beginning_of_month()
first_day =
assigns[:first_day_of_month] ||
socket.assigns[:first_day_of_month] ||
Date.utc_today() |> Date.beginning_of_month()
selected_date = assigns[:selected_date] || Date.utc_today()
grid = CalendarExt.build_calendar_grid(first_day)
{:ok,
socket
|> assign(assigns)
|> assign(:beginning_of_month, beginning_of_month)
|> assign(:selected_date, selected_date)
|> assign(:today, Date.utc_today())
|> assign_calendar_grid()}
|> assign(:first_day_of_month, first_day)
|> assign(:grid, grid)}
end
def handle_event("prev-month", _params, socket) do
new_beginning_of_month =
socket.assigns.beginning_of_month
def handle_event("prev-month", _, socket) do
new_date =
socket.assigns.first_day_of_month
|> Date.shift(month: -1)
|> Date.beginning_of_month()
{:noreply,
socket
|> assign(:beginning_of_month, new_beginning_of_month)
|> assign_calendar_grid()}
assign(socket, first_day_of_month: new_date, grid: CalendarExt.build_calendar_grid(new_date))}
end
def handle_event("next-month", _params, socket) do
new_beginning_of_month =
socket.assigns.beginning_of_month
def handle_event("next-month", _, socket) do
new_date =
socket.assigns.first_day_of_month
|> Date.shift(month: 1)
|> Date.beginning_of_month()
{:noreply,
socket
|> assign(:beginning_of_month, new_beginning_of_month)
|> assign_calendar_grid()}
assign(socket, first_day_of_month: new_date, grid: CalendarExt.build_calendar_grid(new_date))}
end
# --- Selection (Parent IS notified) ---
def handle_event("select-date", %{"date" => date_str}, socket) do
date = Date.from_iso8601!(date_str)
send(self(), {:date_selected, date})
{:noreply, assign(socket, :selected_date, date)}
end
defp assign_calendar_grid(socket) do
first = socket.assigns.beginning_of_month
# Calculate offset to start grid on Monday (Monday = 1)
day_of_week = Date.day_of_week(socket.assigns.beginning_of_month)
days_before = day_of_week - 1
start_date = Date.add(first, -days_before)
grid = Enum.map(0..(@grid_cols * @grid_rows - 1), fn n -> Date.add(start_date, n) end)
assign(socket, :grid, grid)
end
def render(assigns) do
~H"""
<div id={@id} class="calendar-container">
<%!-- Header --%>
<div class="flex items-center justify-between mb-4">
<button
type="button"
phx-click="prev-month"
phx-target={@myself}
class="p-2 rounded-full hover:bg-base-200 text-neutral transition-colors"
class="p-2 hover:bg-base-200 rounded-full"
>
<.icon name="hero-chevron-left" class="w-5 h-5" />
</button>
<h3 class="text-lg font-semibold text-base-content">
{Calendar.strftime(@beginning_of_month, "%B %Y")}
<h3 class="text-lg font-bold capitalize select-none">
{Calendar.strftime(@first_day_of_month, "%B %Y")}
</h3>
<button
type="button"
phx-click="next-month"
phx-target={@myself}
class="p-2 rounded-full hover:bg-base-200 text-neutral transition-colors"
class="p-2 hover:bg-base-200 rounded-full"
>
<.icon name="hero-chevron-right" class="w-5 h-5" />
</button>
</div>
<div class="grid grid-cols-7 text-center text-sm font-medium text-neutral mb-2">
<div class="grid grid-cols-7 text-center text-sm font-medium opacity-70 mb-2 select-none">
<span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span><span>Su</span>
</div>
<div class="grid grid-cols-7 gap-y-2 text-center text-base-content">
<div class="grid grid-cols-7 gap-1">
<%= for date <- @grid do %>
<% is_selected = Date.compare(date, @selected_date) == :eq
is_past = Date.compare(date, @today) == :lt
is_beginning_of_month = date.month == @beginning_of_month.month %>
<% is_current_month = date.month == @first_day_of_month.month
is_selected = @selected_date == date
is_past = Date.compare(date, Date.utc_today()) == :lt %>
<%= if is_beginning_of_month do %>
<%= if is_current_month do %>
<button
type="button"
phx-click={!is_past && "select-date"}
phx-value-date={Date.to_iso8601(date)}
phx-value-date={date}
phx-target={@myself}
disabled={is_past}
class={
[
"p-2 rounded-full transition-colors",
# Styling for past dates (disabled)
is_past && "cursor-not-allowed opacity-40 text-neutral",
# Styling for selected date
is_selected &&
"bg-secondary text-white font-bold shadow-md shadow-secondary/30",
# Styling for regular dates
!is_past && !is_selected &&
"hover:bg-secondary/20"
]
}
class={[
"p-2 rounded-full w-full aspect-square flex items-center justify-center transition-colors",
is_past && "cursor-not-allowed opacity-40 text-neutral",
is_selected && "bg-secondary text-white font-bold shadow-md",
!is_past && !is_selected && "hover:bg-secondary/20"
]}
>
{date.day}
</button>
<% else %>
<div class="p-2"></div>
<div class="p-2 w-full aspect-square"></div>
<% end %>
<% end %>
</div>

View file

@ -1,32 +1,28 @@
defmodule SpazioSolazzoWeb.AssetBookingLive do
defmodule SpazioSolazzoWeb.SpaceBookingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.BookingComponents
require Ash.Query
def mount(%{"asset_id" => asset_id}, _session, socket) do
case BookingSystem.get_asset_by_id(asset_id, load: [:space]) do
{:ok, asset} ->
def mount(%{"space_slug" => space_slug}, _session, socket) do
case BookingSystem.get_space_by_slug(space_slug) do
{:ok, space} ->
selected_date = Date.utc_today()
current_user = socket.assigns[:current_user]
{:ok, time_slots} =
BookingSystem.get_space_time_slots_by_date(asset.space.id, selected_date)
{:ok, bookings} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, selected_date)
time_slots = load_time_slots_with_stats(space, selected_date, current_user)
if connected?(socket) do
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:approved")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:rejected")
end
{:ok,
socket
|> assign(
asset: asset,
space: asset.space,
bookings: bookings,
space: space,
selected_date: selected_date,
selected_time_slot: nil,
show_booking_modal: false,
@ -37,7 +33,7 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
{:error, _error} ->
{:ok,
socket
|> put_flash(:error, "Asset not found")
|> put_flash(:error, "Space not found")
|> push_navigate(to: "/")}
end
end
@ -56,14 +52,50 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
end
def handle_info({:create_booking, booking_data}, socket) do
case parse_booking_data(booking_data) do
{:error, error} -> {:noreply, put_flash(socket, :error, error)}
{:ok, booking_data} -> create_booking(booking_data, socket)
end
end
def handle_info({:date_selected, date}, socket) do
time_slots =
load_time_slots_with_stats(socket.assigns.space, date, socket.assigns.current_user)
{:noreply,
socket
|> assign(
selected_date: date,
time_slots: time_slots
)}
end
def handle_info(
%{topic: "booking:" <> _event, payload: %{data: %{space_id: space_id, date: date}}},
%{assigns: %{space: %{id: space_id}, selected_date: date}} = socket
) do
time_slots =
load_time_slots_with_stats(socket.assigns.space, date, socket.assigns.current_user)
{:noreply,
socket
|> assign(time_slots: time_slots)}
end
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp create_booking(booking_data, socket) do
current_user = socket.assigns.current_user
result =
BookingSystem.create_booking(
socket.assigns.selected_time_slot.id,
socket.assigns.asset.id,
socket.assigns.space.id,
current_user.id,
socket.assigns.selected_date,
socket.assigns.selected_time_slot.start_time,
socket.assigns.selected_time_slot.end_time,
booking_data.customer_name,
current_user.email,
booking_data.customer_phone,
@ -83,48 +115,34 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
{:noreply,
socket
|> assign(show_booking_modal: false)
|> put_flash(:error, "Failed to create booking.}")}
|> put_flash(:error, "Failed to create booking request.")}
end
end
def handle_info(
%{topic: "booking:created", payload: %{data: %{asset_id: asset_id, date: date}}},
%{assigns: %{asset: %{id: asset_id}, selected_date: date}} = socket
) do
{:ok, bookings} = BookingSystem.list_active_asset_bookings_by_date(asset_id, date)
{:noreply, assign(socket, bookings: bookings)}
defp parse_booking_data(%{customer_name: ""}) do
{:error, "Please fill all the required fields to create a booking request."}
end
def handle_info(
%{topic: "booking:cancelled", payload: %{data: %{asset_id: asset_id, date: date}}},
%{assigns: %{asset: %{id: asset_id}, selected_date: date}} = socket
) do
{:ok, bookings} = BookingSystem.list_active_asset_bookings_by_date(asset_id, date)
{:noreply, assign(socket, bookings: bookings)}
defp parse_booking_data(
%{
customer_name: _,
customer_phone: _,
customer_comment: _
} = form
) do
{:ok, form}
end
def handle_info({:date_selected, date}, socket) do
{:ok, time_slots} =
BookingSystem.get_space_time_slots_by_date(socket.assigns.space.id, date)
{:ok, bookings} =
BookingSystem.list_active_asset_bookings_by_date(socket.assigns.asset.id, date)
{:noreply,
assign(socket,
selected_date: date,
time_slots: time_slots,
bookings: bookings
)}
end
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp slot_booked?(time_slot_id, bookings) do
Enum.any?(bookings, fn booking ->
booking.time_slot_template_id == time_slot_id
end)
defp load_time_slots_with_stats(space, date, current_user) do
BookingSystem.get_space_time_slots_by_date!(space.id, date,
load: [
booking_stats: %{
date: date,
space_id: space.id,
capacity: space.capacity,
user_id: current_user.id
}
]
)
end
end

View file

@ -0,0 +1,117 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<section class="mx-auto max-w-[1200px] px-6 py-10">
<.back_to_link
navigate={"/#{@space.slug}"}
value={"Back to #{@space.name}"}
/>
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl lg:text-6xl font-black text-slate-900 dark:text-white tracking-tight mb-4">
{@space.name}
</h1>
<p class="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
{@space.description}
</p>
</div>
<div class="max-w-4xl mx-auto bg-white dark:bg-slate-800 rounded-3xl p-8 md:p-12 border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none">
<h2 class="text-2xl font-bold text-slate-900 dark:text-white mb-8">
Available Time Slots
</h2>
<div class="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<.live_component
module={SpazioSolazzoWeb.BookingCalendarLiveComponent}
id="booking-calendar"
selected_date={@selected_date}
/>
<div class="time-slots-wrapper">
<p class="mb-4 text-slate-500 dark:text-slate-400">
Selected day:
<span class="font-bold text-slate-900 dark:text-white">
{Calendar.strftime(@selected_date, "%A, %B %d, %Y")}
</span>
</p>
<div class="max-h-80 overflow-y-auto pr-4 space-y-3">
<%= if @time_slots == [] do %>
<div class="text-center py-8 text-slate-500 dark:text-slate-400">
No time slots available for this date
</div>
<% else %>
<%= for time_slot <- @time_slots do %>
<.time_slot_card time_slot={time_slot} />
<% end %>
<% end %>
</div>
</div>
</div>
<div class="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700 text-center">
<p class="text-base font-medium text-sky-500 dark:text-sky-400 flex items-center justify-center gap-2">
<.icon name="hero-credit-card" class="w-5 h-5" /> Payment due upon arrival.
</p>
</div>
</div>
</section>
<.live_component
module={SpazioSolazzoWeb.BookingFormLiveComponent}
id="booking-modal"
show={@show_booking_modal}
selected_time_slot={@selected_time_slot}
space={@space}
selected_date={@selected_date}
current_user={@current_user}
slot_availability={
if @selected_time_slot do
@selected_time_slot.booking_stats.availability_status
else
:available
end
}
on_cancel={JS.push("cancel_booking")}
/>
<%= if @show_success_modal do %>
<div
id="success-modal"
class="fixed inset-0 bg-slate-900/20 backdrop-blur-sm flex items-center justify-center p-4 z-50"
phx-click="close_success_modal"
>
<div class="bg-white dark:bg-slate-800 rounded-3xl border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none max-w-md w-full p-6 text-center">
<div class="mb-4">
<div class="mx-auto flex items-center justify-center h-14 w-14 rounded-full bg-green-100 dark:bg-green-900/20 mb-3">
<svg
class="h-8 w-8 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
Request Submitted!
</h3>
<p class="text-slate-600 dark:text-slate-400 text-sm">
Your booking request is pending approval. You'll receive an email confirmation shortly.
</p>
</div>
<button
phx-click="close_success_modal"
class="w-full bg-green-500 text-white px-4 py-2 rounded-lg font-medium hover:bg-green-600 transition-colors text-sm"
>
Close
</button>
</div>
</div>
<% end %>
</Layouts.app>

View file

@ -2,24 +2,12 @@ defmodule SpazioSolazzoWeb.CoworkingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
# Landing page components
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("coworking")
{:ok, assets} = BookingSystem.get_space_assets(space.id)
images = [
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
]
{:ok,
socket
|> assign(
space: space,
assets: assets,
images: images
)}
{:ok, assign(socket, space: space)}
end
end

View file

@ -1,11 +1,15 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.page_header
booking_path="#interactive-floor-plan"
booking_label="Explore Desks & Book"
booking_path={~p"/book/space/#{@space.slug}"}
booking_label="Book Space"
price="€25"
price_unit="day"
capacity="5 Desks"
images={@images}
capacity={@space.capacity}
images={[
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
]}
>
<:title>{@space.name}</:title>
<:description>
@ -38,43 +42,6 @@
/>
</.features_section>
<section class="py-20 px-6 bg-base-100" id="interactive-floor-plan">
<div class="mx-auto max-w-[1200px]">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-base-content mb-4">
Interactive Floor Plan
</h2>
<p class="text-neutral max-w-lg mx-auto">
Select any desk to customize your booking details on the next page, where availability is confirmed.
</p>
</div>
<div class="bg-base-200 rounded-3xl p-8 md:p-12 border border-base-300 shadow-xl relative overflow-hidden">
<div class="absolute top-6 right-6 opacity-10 pointer-events-none select-none">
<.icon name="hero-building-office-2" class="w-32 h-32 text-secondary" />
</div>
<div class="flex flex-col items-center gap-10">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 w-full max-w-3xl">
<.link
:for={asset <- @assets}
navigate={~p"/book/asset/#{asset.id}"}
class="group relative flex flex-col items-center gap-3 cursor-pointer"
>
<div class="w-full aspect-[4/3] rounded-xl bg-base-100 border-2 border-base-200 group-hover:border-secondary group-hover:shadow-lg group-hover:shadow-secondary/20 transition-all duration-300 flex items-center justify-center relative">
<.icon
name="hero-computer-desktop"
class="w-12 h-12 text-base-300 group-hover:text-secondary transition-colors"
/>
</div>
<span class="text-sm font-bold text-neutral group-hover:text-secondary transition-colors">
{asset.name}
</span>
</.link>
</div>
</div>
</div>
</div>
</section>
<.house_rules title="Coworking House Rules">
<:rule>Please keep phone calls to the dedicated booths.</:rule>
<:rule>Clean your desk area before leaving.</:rule>

View file

@ -2,24 +2,12 @@ defmodule SpazioSolazzoWeb.MeetingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
# Landing page components
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("meeting")
{:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
images = [
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
]
{:ok,
socket
|> assign(
space: space,
asset: asset,
images: images
)}
{:ok, assign(socket, space: space)}
end
end

View file

@ -1,62 +1,49 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.page_header
booking_path={~p"/book/asset/#{@asset.id}"}
price="€40"
booking_path={~p"/book/space/#{@space.slug}"}
booking_label="Book Space"
price="€35"
price_unit="hour"
capacity="Up to 8 People"
images={@images}
capacity={@space.capacity}
images={[
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
]}
>
<:title>{@space.name}</:title>
<:description>
A private, sun-drenched sanctuary designed for focus and collaboration. Step into a space where Sicilian charm meets modern productivity.
A private, professional space equipped for your team meetings, client presentations, or brainstorming sessions.
</:description>
</.page_header>
<.features_section
title="Everything you need to succeed"
description="We've equipped the Meeting Room with top-tier amenities so you can focus on the agenda, not the logistics."
title="Built for collaboration"
description="A dedicated meeting environment with all the professional amenities you need."
>
<:feature
icon="hero-tv"
title="4K Presentation"
description="Crystal clear 65&quot; monitor ready for your slide decks. Connect via HDMI or wireless casting."
color="sky"
icon="hero-presentation-chart-line"
title="Pro Presentation Setup"
description="75-inch 4K display, wireless presentation tools, and high-quality audio system."
color="blue"
/>
<:feature
icon="hero-video-camera"
title="Video Conferencing"
description="Logitech Rally bar with AI framing and noise cancellation for seamless remote meetings."
color="orange"
/>
<:feature
icon="hero-pencil"
title="Creative Tools"
description="Wall-to-wall glass whiteboard, sticky notes, and markers to capture every brainstorming session."
color="yellow"
/>
<:feature
icon="hero-wifi"
title="Fiber Internet"
description="Dedicated 1Gbps symmetrical fiber line ensuring you never drop a call or buffer a video."
description="Professional webcam and microphone setup optimized for hybrid meetings."
color="emerald"
/>
<:feature
icon="hero-home"
title="Ergonomic Comfort"
description="Herman Miller chairs and a solid oak table designed to keep you comfortable during long sessions."
color="indigo"
/>
<:feature
icon="hero-cake"
title="Catering Available"
description="Pre-order coffee carafes, Sicilian pastries, or light lunch options from local partners."
title="Hospitality Station"
description="Coffee, tea, and water service to keep your team refreshed and focused."
color="purple"
/>
</.features_section>
<.house_rules title="House Rules">
<:rule>Please clean the whiteboard after use.</:rule>
<:rule>Outside food is allowed, but please be tidy.</:rule>
<:rule>Cancel up to 24 hours before for a full refund.</:rule>
<.house_rules title="Meeting Room Guidelines">
<:rule>Please arrive on time and end on schedule.</:rule>
<:rule>Reset the room to its original setup after use.</:rule>
<:rule>Technical support is available upon request.</:rule>
</.house_rules>
</Layouts.app>

View file

@ -2,25 +2,12 @@ defmodule SpazioSolazzoWeb.MusicLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
# Landing page components
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("music")
{:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
images = [
"https://lh3.googleusercontent.com/aida-public/AB6AXuD1wkxK48dk7i5XYX6JL-O1egrsdLjcmOg7N4EB76QtUvhzR7lZQadprIT9rLPsroUjazftFRpp_z26wb8lHaUW9XyucGlKG3qG40oT5iaKWwqVI1drNKJDJgVBkmNjw4u_D5vig_C1pf6bgGZnPaOV2tnnmlexxHJIDQYZzfg1GGwgywBvpGLz_u2_jvkbyMo3_m5roM09PjonFEfGIHxjm0vClW1DAOX45IrT87A85OdAXEu2EPyB8oW9WzmolOn4DFj22vKWSbVD",
"https://lh3.googleusercontent.com/aida-public/AB6AXuB3fJu4mgZaw8GP1OC2SjquJZJmnRlY_OHD4fO4AAd_KHd5BYnW1i0egrskoEfK_uCdK4pQu5kMf8pF9h_KXE0wYQAROnTBTJ4YmBpHui9nv8wz44VENo2p-lA3rW8xhQhiYzlAhHJlhgOdZluVp9eYvsZxGM76QkDXMcBQz1Ka5ZfRMNgddo1RS76IPaxbQIvpOh_55uW87bAiGAvhcE8GrIi2ugpiJ64Rdou1uZLD1bPWxUvyLtpTFFLr2vfVjq7OpVYiGnLaGstS",
"https://lh3.googleusercontent.com/aida-public/AB6AXuBVY6j_kvSUC0trHVnwvszpxZa58CpY0sGTF6m6lPQJkFlN-GnK1ofNaSn8PU1JnmPPAl7B196LoYq4SfawlDFrg2ADKKr65cOE0jq2L9w-cXrkPxE4poylrIeKX8zP1JsIS5obvU5_HAG074RjeYSWFsV5Z7wQF0ktZlYL6m462hsl-xdLQWQiBLZOHNsBf6jrZieUst9dKUlec6hzWOqcbIXuugBJW5fklJmMti9CDQynn1XZ5I5gZYEL47tW2Ku9u_zEEpfqmEKF"
]
{:ok,
socket
|> assign(
space: space,
asset: asset,
images: images
)}
{:ok, assign(socket, space: space)}
end
end

View file

@ -1,62 +1,49 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.page_header
booking_path={~p"/book/asset/#{@asset.id}"}
price="€25"
booking_path={~p"/book/space/#{@space.slug}"}
booking_label="Book Space"
price="€50"
price_unit="hour"
capacity="Up to 5 People"
images={@images}
capacity={@space.capacity}
images={[
"https://lh3.googleusercontent.com/aida-public/AB6AXuD1wkxK48dk7i5XYX6JL-O1egrsdLjcmOg7N4EB76QtUvhzR7lZQadprIT9rLPsroUjazftFRpp_z26wb8lHaUW9XyucGlKG3qG40oT5iaKWwqVI1drNKJDJgVBkmNjw4u_D5vig_C1pf6bgGZnPaOV2tnnmlexxHJIDQYZzfg1GGwgywBvpGLz_u2_jvkbyMo3_m5roM09PjonFEfGIHxjm0vClW1DAOX45IrT87A85OdAXEu2EPyB8oW9WzmolOn4DFj22vKWSbVD",
"https://lh3.googleusercontent.com/aida-public/AB6AXuB3fJu4mgZaw8GP1OC2SjquJZJmnRlY_OHD4fO4AAd_KHd5BYnW1i0egrskoEfK_uCdK4pQu5kMf8pF9h_KXE0wYQAROnTBTJ4YmBpHui9nv8wz44VENo2p-lA3rW8xhQhiYzlAhHJlhgOdZluVp9eYvsZxGM76QkDXMcBQz1Ka5ZfRMNgddo1RS76IPaxbQIvpOh_55uW87bAiGAvhcE8GrIi2ugpiJ64Rdou1uZLD1bPWxUvyLtpTFFLr2vfVjq7OpVYiGnLaGstS",
"https://lh3.googleusercontent.com/aida-public/AB6AXuBVY6j_kvSUC0trHVnwvszpxZa58CpY0sGTF6m6lPQJkFlN-GnK1ofNaSn8PU1JnmPPAl7B196LoYq4SfawlDFrg2ADKKr65cOE0jq2L9w-cXrkPxE4poylrIeKX8zP1JsIS5obvU5_HAG074RjeYSWFsV5Z7wQF0ktZlYL6m462hsl-xdLQWQiBLZOHNsBf6jrZieUst9dKUlec6hzWOqcbIXuugBJW5fklJmMti9CDQynn1XZ5I5gZYEL47tW2Ku9u_zEEpfqmEKF"
]}
>
<:title>{@space.name}</:title>
<:description>
A relaxed, creative space for jamming, practice sessions, or just unwinding with instruments. A casual vibe, not a soundproof studio, perfect for connecting through music.
A professionally designed music rehearsal and recording space in the heart of Palermo. Perfect for bands, solo artists, and content creators.
</:description>
</.page_header>
<.features_section
title="Jam, Practice, Create"
description="The Music Room is equipped with essentials for a good session. It's not a pro studio, but it has soul and everything you need to start playing."
title="Professional Audio Environment"
description="State-of-the-art equipment and acoustically treated space for your creative sessions."
>
<:feature
icon="hero-musical-note"
title="House Instruments"
description="Includes a digital piano, acoustic guitar, and a basic drum kit ready for your use."
color="sky"
/>
<:feature
icon="hero-speaker-wave"
title="PA System"
description="Two active speakers and a simple 4-channel mixer to plug in vocals or keyboards."
color="orange"
/>
<:feature
icon="hero-home"
title="Relaxed Vibe"
description="Comfortable seating, warm lighting, and rugs creates a cozy atmosphere for creative flow."
color="yellow"
title="Premium Sound System"
description="High-end monitors, professional mixing console, and complete PA system ready to use."
color="violet"
/>
<:feature
icon="hero-microphone"
title="Basic Mics"
description="Two Shure SM58 microphones with stands available for vocals or acoustic instruments."
color="emerald"
/>
<:feature
icon="hero-speaker-x-mark"
title="Not Soundproof"
description="Please note this is a community space. Some sound bleed occurs; keep volumes reasonable."
color="indigo"
title="Recording Ready"
description="Multi-track recording capability with professional condenser microphones and interfaces."
color="pink"
/>
<:feature
icon="hero-musical-note"
title="Bring Your Gear"
description="Feel free to bring your own amps, pedals, or specific instruments to dial in your tone."
color="purple"
title="Full Backline"
description="Drum kit, bass and guitar amplifiers, keyboards - everything you need to play."
color="orange"
/>
</.features_section>
<.house_rules title="Jam Session Rules">
<:rule>Reset instruments to their original places.</:rule>
<:rule>No drinks on top of the piano or amps.</:rule>
<:rule>Be mindful of volume for our coworking neighbors.</:rule>
<.house_rules title="Music Studio Guidelines">
<:rule>Respect noise level limits and time slots.</:rule>
<:rule>Handle equipment with care and return items to their places.</:rule>
<:rule>Clean up after your session.</:rule>
</.house_rules>
</Layouts.app>

View file

@ -1,11 +1,9 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="mx-auto max-w-[800px] px-6 py-12">
<div class="mb-10">
<.back_to_link
navigate={~p"/"}
value="Back to Home"
/>
</div>
<.back_to_link
navigate={~p"/"}
value="Back to Home"
/>
<div class="mb-10 text-center">
<h1 class="text-4xl font-black text-base-content tracking-tight">

View file

@ -25,8 +25,6 @@ defmodule SpazioSolazzoWeb.Router do
scope "/", SpazioSolazzoWeb do
pipe_through :browser
get "/bookings/confirm", BookingController, :confirm
get "/bookings/cancel", BookingController, :cancel
get "/sign-out", AuthController, :sign_out
get "/auth/magic/sign-in", AuthController, :magic_sign_in
get "/auth/failure", AuthController, :auth_failure
@ -53,7 +51,8 @@ defmodule SpazioSolazzoWeb.Router do
on_mount: [
{SpazioSolazzoWeb.LiveUserAuth, :live_user_required}
] do
live "/book/asset/:asset_id", AssetBookingLive
live "/book/space/:space_slug", SpaceBookingLive
live "/bookings/cancel", BookingCancellationLive
live "/profile", ProfileLive
end
@ -62,6 +61,8 @@ defmodule SpazioSolazzoWeb.Router do
{SpazioSolazzoWeb.LiveUserAuth, :live_admin_required}
] do
live "/admin/dashboard", Admin.DashboardLive
live "/admin/bookings", Admin.BookingManagementLive
live "/admin/walk-in", Admin.WalkInLive
end
end
@ -81,14 +82,4 @@ defmodule SpazioSolazzoWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
if Application.compile_env(:spazio_solazzo, :dev_routes) do
import AshAdmin.Router
scope "/admin" do
pipe_through :browser
ash_admin "/"
end
end
end

View file

@ -50,7 +50,6 @@ defmodule SpazioSolazzo.MixProject do
defp deps do
[
{:ash, "~> 3.0"},
{:ash_admin, "~> 0.13"},
{:ash_authentication, "~> 4.0"},
{:ash_authentication_phoenix, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},

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

@ -1,29 +0,0 @@
defmodule SpazioSolazzo.Repo.Migrations.SetPhoneNumberAsNullable do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
modify :phone_number, :text, null: true
end
alter table(:bookings) do
modify :customer_phone, :text, null: true
end
end
def down do
alter table(:bookings) do
modify :customer_phone, :text, null: false
end
alter table(:users) do
modify :phone_number, :text, null: false
end
end
end

View file

@ -1,21 +0,0 @@
defmodule SpazioSolazzo.Repo.Migrations.AddRoleToUsers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
add :role, :text, null: false, default: "customer"
end
end
def down do
alter table(:users) do
remove :role
end
end
end

View file

@ -1,4 +1,4 @@
defmodule SpazioSolazzo.Repo.Migrations.SetupResourcesExtensions1 do
defmodule SpazioSolazzo.Repo.Migrations.CreateBaseResourcesExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback

View file

@ -1,4 +1,4 @@
defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
defmodule SpazioSolazzo.Repo.Migrations.CreateBaseResources do
@moduledoc """
Updates resources based on their most recent snapshots.
@ -12,7 +12,8 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :email, :citext, null: false
add :name, :text, null: false
add :phone_number, :text, null: false
add :phone_number, :text
add :role, :text, null: false, default: "customer"
end
create unique_index(:users, [:email], name: "users_unique_email_index")
@ -59,6 +60,7 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
add :name, :text, null: false
add :description, :text, null: false
add :slug, :text, null: false
add :capacity, :bigint, null: false
end
create unique_index(:spaces, [:name], name: "spaces_unique_name_index")
@ -67,14 +69,18 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
create table(:bookings, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :start_datetime, :utc_datetime, null: false
add :end_datetime, :utc_datetime, null: false
add :date, :date, null: false
add :customer_name, :text, null: false
add :customer_email, :text, null: false
add :start_time, :time, null: false
add :end_time, :time, null: false
add :customer_phone, :text, null: false
add :customer_phone, :text
add :customer_comment, :text
add :state, :text, null: false, default: "reserved"
add :cancellation_reason, :text
add :rejection_reason, :text
add :state, :text, null: false, default: "requested"
add :inserted_at, :utc_datetime_usec,
null: false,
@ -84,87 +90,34 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :asset_id, :uuid
add :time_slot_template_id, :uuid
add :user_id, :uuid
end
create table(:assets, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
end
alter table(:bookings) do
modify :asset_id,
references(:assets,
column: :id,
name: "bookings_asset_id_fkey",
type: :uuid,
prefix: "public"
)
modify :time_slot_template_id,
references(:time_slot_templates,
column: :id,
name: "bookings_time_slot_template_id_fkey",
type: :uuid,
prefix: "public"
)
modify :user_id,
references(:users,
column: :id,
name: "bookings_user_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :nilify_all
)
end
create index(:bookings, [:user_id])
alter table(:assets) do
add :name, :text, null: false
add :space_id,
references(:spaces,
column: :id,
name: "assets_space_id_fkey",
name: "bookings_space_id_fkey",
type: :uuid,
prefix: "public"
), null: false
add :user_id,
references(:users,
column: :id,
name: "bookings_user_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :nilify_all
)
end
create unique_index(:assets, [:name, :space_id], name: "assets_unique_name_per_space_index")
create index(:bookings, [:user_id])
end
def down do
drop_if_exists unique_index(:assets, [:name, :space_id],
name: "assets_unique_name_per_space_index"
)
drop constraint(:assets, "assets_space_id_fkey")
alter table(:assets) do
remove :space_id
remove :name
end
drop_if_exists index(:bookings, [:user_id])
drop constraint(:bookings, "bookings_asset_id_fkey")
drop constraint(:bookings, "bookings_time_slot_template_id_fkey")
drop constraint(:bookings, "bookings_space_id_fkey")
drop constraint(:bookings, "bookings_user_id_fkey")
alter table(:bookings) do
modify :user_id, :uuid
modify :time_slot_template_id, :uuid
modify :asset_id, :uuid
end
drop table(:assets)
drop table(:bookings)
drop_if_exists unique_index(:spaces, [:slug], name: "spaces_unique_slug_index")
@ -172,6 +125,7 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
drop_if_exists unique_index(:spaces, [:name], name: "spaces_unique_name_index")
alter table(:spaces) do
remove :capacity
remove :slug
remove :description
remove :name

View file

@ -0,0 +1,29 @@
defmodule SpazioSolazzo.Repo.Migrations.AddBookingIndexes do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create index(:bookings, [:end_datetime])
create index(:bookings, [:start_datetime])
create index(:bookings, [:space_id, :state])
create index(:bookings, [:space_id, :start_datetime, :end_datetime])
end
def down do
drop_if_exists index(:bookings, [:space_id, :start_datetime, :end_datetime])
drop_if_exists index(:bookings, [:space_id, :state])
drop_if_exists index(:bookings, [:start_datetime])
drop_if_exists index(:bookings, [:end_datetime])
end
end

View file

@ -17,45 +17,39 @@ case BookingSystem.Space |> Ash.read() do
:ok
end
# Create Coworking Space
# Create Coworking Space (capacity: 10)
coworking =
BookingSystem.create_space!("Arcipelago", "coworking", "Flexible desk spaces for remote work")
BookingSystem.create_space!(
"Arcipelago",
"coworking",
"Flexible desk spaces for remote work",
10
)
IO.puts("✓ Created Coworking space")
# Create Meeting Room Space
# Create Meeting Room Space (capacity: 1)
meeting =
BookingSystem.create_space!(
"Media room",
"meeting",
"Private conference room for your meetings"
"Private conference room for your meetings",
1
)
IO.puts("✓ Created Meeting Room space")
# Create Music Studio Space
music = BookingSystem.create_space!("Hall", "music", "Tailored for band rehearsals.")
# Create Music Studio Space (capacity: 1)
music =
BookingSystem.create_space!(
"Hall",
"music",
"Tailored for band rehearsals.",
1
)
IO.puts("✓ Created Music Studio space")
# Create Coworking Tables (Assets)
tables =
for i <- 1..5 do
BookingSystem.create_asset!("Table #{i}", coworking.id)
end
IO.puts("✓ Created #{length(tables)} coworking tables")
# Create Meeting Room Asset
BookingSystem.create_asset!("Main Conference Room", meeting.id)
IO.puts("✓ Created meeting room asset")
# Create Music Studio Asset
BookingSystem.create_asset!("Recording Studio", music.id)
IO.puts("✓ Created music studio asset")
# Create Coworking Time Slot Templates for each weekday
coworking_slots = [
%{start_time: ~T[09:00:00], end_time: ~T[13:00:00]},

View file

@ -1,93 +0,0 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "assets_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "spaces"
},
"scale": null,
"size": null,
"source": "space_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "9FAD08E854AEBC8A2C7C7466BBC5254CAFB16C2A860034688C3C1142E183052C",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "assets_unique_name_per_space_index",
"keys": [
{
"type": "atom",
"value": "name"
},
{
"type": "atom",
"value": "space_id"
}
],
"name": "unique_name_per_space",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "assets"
}

View file

@ -12,6 +12,30 @@
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "start_datetime",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "end_datetime",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
@ -73,7 +97,7 @@
"type": "time"
},
{
"allow_nil?": false,
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
@ -96,9 +120,33 @@
"source": "customer_comment",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "cancellation_reason",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "rejection_reason",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"reserved\"",
"default": "\"requested\"",
"generated?": false,
"precision": null,
"primary_key?": false,
@ -133,7 +181,7 @@
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
@ -151,47 +199,16 @@
"global": null,
"strategy": null
},
"name": "bookings_asset_id_fkey",
"name": "bookings_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "assets"
"table": "spaces"
},
"scale": null,
"size": null,
"source": "asset_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_time_slot_template_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "time_slot_templates"
},
"scale": null,
"size": null,
"source": "time_slot_template_id",
"source": "space_id",
"type": "uuid"
},
{
@ -231,7 +248,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "D4E1D8A61AAAA83530EF07DE6BB15175AE052ADE62D06E538C222576218F0289",
"hash": "CBA4F1C24A9B1AB8A2E4EF42917C1DE5F143C74D0D175D1A29156ECCE6FD5660",
"identities": [],
"multitenancy": {
"attribute": null,

View file

@ -12,6 +12,30 @@
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "start_datetime",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "end_datetime",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
@ -96,9 +120,33 @@
"source": "customer_comment",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "cancellation_reason",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "rejection_reason",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"reserved\"",
"default": "\"requested\"",
"generated?": false,
"precision": null,
"primary_key?": false,
@ -133,7 +181,7 @@
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
@ -151,47 +199,16 @@
"global": null,
"strategy": null
},
"name": "bookings_asset_id_fkey",
"name": "bookings_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "assets"
"table": "spaces"
},
"scale": null,
"size": null,
"source": "asset_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_time_slot_template_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "time_slot_templates"
},
"scale": null,
"size": null,
"source": "time_slot_template_id",
"source": "space_id",
"type": "uuid"
},
{
@ -228,10 +245,114 @@
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"space_id",
"start_datetime",
"end_datetime"
],
"fields": [
{
"type": "atom",
"value": "space_id"
},
{
"type": "atom",
"value": "start_datetime"
},
{
"type": "atom",
"value": "end_datetime"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"space_id",
"state"
],
"fields": [
{
"type": "atom",
"value": "space_id"
},
{
"type": "atom",
"value": "state"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"start_datetime"
],
"fields": [
{
"type": "atom",
"value": "start_datetime"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"end_datetime"
],
"fields": [
{
"type": "atom",
"value": "end_datetime"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "1480C13D76AD8CE079362CC851CF250063914A40A6CA48182E3D3B5D83CD174A",
"hash": "5DB2211C98FA47BC6D744D8FD763CC8920F067DFD092EC120CBD2115E26810CF",
"identities": [],
"multitenancy": {
"attribute": null,

View file

@ -47,6 +47,18 @@
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "capacity",
"type": "bigint"
}
],
"base_filter": null,
@ -54,7 +66,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "29965B10015A4BDE39A648F85CE4FDB39DDFC21E0CB18903C7F9677E11B11D21",
"hash": "900D00B10E6347AEAB477B96DE6C11FCC9AD623D34E5DB404EA57E89E8A77E5B",
"identities": [
{
"all_tenants?": false,

View file

@ -1,82 +0,0 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "57A4F6BB98AF78EE399E42DAC6AAD95429A43D3A110B792DD6A05735591A5C62",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "users"
}

View file

@ -1,82 +0,0 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6D20D84C076B09FEAFF7A68C69930AF48936A628BBEE432695F0ECC02B0F4EFA",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "users"
}

View file

@ -66,7 +66,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "0B279BA9251EA91451BB80DF86AF81ECCA5395011864BE01BE1C369420AD9C18",
"hash": "8150DCC90B96652CEC9629DE7283AED0E40B0E9A860BE5B1F25BC7E6BF8C0570",
"identities": [
{
"all_tenants?": false,

View file

@ -43,14 +43,15 @@ defmodule SpazioSolazzo.Accounts.UserTest do
describe "terminate_account with delete_history: false (anonymization)" do
test "deletes user but preserves bookings with nullified user_id" do
user = register_user("delete@example.com")
{_space, asset, time_slot} = create_booking_fixtures()
{space, _time_slot} = create_booking_fixtures()
{:ok, booking1} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
Date.utc_today(),
~T[09:00:00],
~T[11:00:00],
"John Doe",
"john@example.com",
"+393627384027",
@ -59,10 +60,11 @@ defmodule SpazioSolazzo.Accounts.UserTest do
{:ok, booking2} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
Date.add(Date.utc_today(), 1),
~T[09:00:00],
~T[11:00:00],
"Jane Doe",
"jane@example.com",
"+393627384028",
@ -88,60 +90,46 @@ defmodule SpazioSolazzo.Accounts.UserTest do
test "cancels future confirmed bookings before anonymizing" do
user = register_user("cancel@example.com")
{_space, asset, time_slot} = create_booking_fixtures()
{space, _time_slot} = create_booking_fixtures()
future_date = Date.add(Date.utc_today(), 7)
{:ok, future_booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
future_date,
~T[09:00:00],
~T[11:00:00],
"Future User",
"future@example.com",
"+393627384029",
"future booking"
)
{:ok, past_booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.add(Date.utc_today(), -7),
"Past User",
"past@example.com",
"+393627384030",
"past booking"
)
future_booking_id = future_booking.id
past_booking_id = past_booking.id
:ok = Accounts.terminate_account(user, false, actor: user)
{:ok, cancelled_booking} = Ash.get(Booking, future_booking_id, authorize?: false)
{:ok, preserved_past_booking} = Ash.get(Booking, past_booking_id, authorize?: false)
assert cancelled_booking.state == :cancelled
assert cancelled_booking.user_id == nil
assert preserved_past_booking.user_id == nil
end
end
describe "terminate_account with delete_history: true (hard delete)" do
test "deletes user and all associated bookings permanently" do
user = register_user("harddelete@example.com")
{_space, asset, time_slot} = create_booking_fixtures()
{space, _time_slot} = create_booking_fixtures()
{:ok, booking1} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
Date.utc_today(),
~T[09:00:00],
~T[11:00:00],
"Delete Me",
"deleteme@example.com",
"+393627384031",
@ -150,10 +138,11 @@ defmodule SpazioSolazzo.Accounts.UserTest do
{:ok, booking2} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
Date.add(Date.utc_today(), 1),
~T[09:00:00],
~T[11:00:00],
"Delete Me Too",
"deletemetoo@example.com",
"+393627384032",
@ -189,11 +178,10 @@ defmodule SpazioSolazzo.Accounts.UserTest do
BookingSystem.create_space(
"Test Space #{unique_id}",
"test-space-#{unique_id}",
"Test description"
"Test description",
10
)
{:ok, asset} = BookingSystem.create_asset("Test Asset", space.id)
{:ok, time_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
@ -202,6 +190,6 @@ defmodule SpazioSolazzo.Accounts.UserTest do
space.id
)
{space, asset, time_slot}
{space, time_slot}
end
end

View file

@ -1,51 +0,0 @@
defmodule SpazioSolazzo.BookingSystem.AssetTest do
use ExUnit.Case, async: true
use SpazioSolazzo.DataCase
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Asset
setup do
{:ok, space} = BookingSystem.create_space("AssetSpace", "assetspace", "desc")
%{space: space}
end
test "prevents duplicate asset names within the system", %{space: space} do
assert {:ok, _} = BookingSystem.create_asset("T1", space.id)
assert {:error, error} = BookingSystem.create_asset("T1", space.id)
message = Ash.Error.error_descriptions(error)
assert String.contains?(message, "already been taken")
end
test "allows same asset name for different spaces", %{space: space} do
assert {:ok, _} = BookingSystem.create_asset("T1", space.id)
# create another space
{:ok, other_space} = BookingSystem.create_space("OtherSpace", "otherspace", "desc")
# same name in different space should succeed
assert {:ok, _} = BookingSystem.create_asset("T1", other_space.id)
end
test "can get single asset by space id", %{space: space} do
assert {:ok, expected_asset} = BookingSystem.create_asset("T1", space.id)
assert {:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
assert asset.id == expected_asset.id
end
test "can get multiple assets by space id", %{space: space} do
assert {:ok, _} = BookingSystem.create_asset("T1", space.id)
assert {:ok, _} = BookingSystem.create_asset("T2", space.id)
assert {:ok, _} = BookingSystem.create_asset("T3", space.id)
assert {:ok,
[
%Asset{name: "T1"},
%Asset{name: "T2"},
%Asset{name: "T3"}
]} =
BookingSystem.get_space_assets(space.id)
end
end

View file

@ -0,0 +1,133 @@
defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorkerTest do
use SpazioSolazzo.DataCase, async: true
alias SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorker
alias Swoosh.Adapters.Local.Storage.Memory
describe "perform/1" do
test "sends confirmation email to customer" do
job_args = %{
"booking_id" => "test-booking-id",
"customer_name" => "John Doe",
"customer_email" => "john@example.com",
"customer_phone" => "+1234567890",
"customer_comment" => "Test comment",
"space_name" => "Coworking Space",
"start_datetime" => "2026-02-02T09:00:00Z",
"end_datetime" => "2026-02-02T13:00:00Z"
}
assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
# Verify customer email was sent
emails = Memory.all()
assert Enum.any?(emails, fn email ->
email.to == [{"John Doe", "john@example.com"}]
end)
end
test "sends notification email to admin" do
job_args = %{
"booking_id" => "test-booking-id",
"customer_name" => "John Doe",
"customer_email" => "john@example.com",
"customer_phone" => "+1234567890",
"customer_comment" => "Test comment",
"space_name" => "Coworking Space",
"start_datetime" => "2026-02-02T09:00:00Z",
"end_datetime" => "2026-02-02T13:00:00Z"
}
admin_email = Application.get_env(:spazio_solazzo, :admin_email)
assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
# Verify admin email was sent
emails = Memory.all()
assert Enum.any?(emails, fn email ->
email.to == [{"", admin_email}]
end)
end
test "sends both customer and admin emails in single job execution" do
job_args = %{
"booking_id" => "test-booking-id",
"customer_name" => "Jane Smith",
"customer_email" => "jane@example.com",
"customer_phone" => "+1234567890",
"customer_comment" => "Another test",
"space_name" => "Meeting Room",
"start_datetime" => "2026-02-03T14:00:00Z",
"end_datetime" => "2026-02-03T18:00:00Z"
}
admin_email = Application.get_env(:spazio_solazzo, :admin_email)
assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
# Both emails should be sent
emails = Memory.all()
assert length(emails) == 2
email_recipients = Enum.map(emails, fn email -> email.to end)
assert [{"Jane Smith", "jane@example.com"}] in email_recipients
assert [{"", admin_email}] in email_recipients
end
test "customer email contains booking details" do
job_args = %{
"booking_id" => "test-booking-id",
"customer_name" => "Test User",
"customer_email" => "test@example.com",
"customer_phone" => "+1234567890",
"customer_comment" => "Test",
"space_name" => "Music Room",
"start_datetime" => "2026-02-04T10:00:00Z",
"end_datetime" => "2026-02-04T12:00:00Z"
}
assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
emails = Memory.all()
customer_email =
Enum.find(emails, fn email ->
email.to == [{"Test User", "test@example.com"}]
end)
assert customer_email != nil
assert String.contains?(customer_email.html_body, "Music Room")
assert String.contains?(customer_email.html_body, "Wednesday, February 04")
end
test "admin email contains customer information" do
job_args = %{
"booking_id" => "test-booking-id",
"customer_name" => "Admin Test",
"customer_email" => "admin.test@example.com",
"customer_phone" => "+1234567890",
"customer_comment" => "Admin comment",
"space_name" => "Coworking Space",
"start_datetime" => "2026-02-05T09:00:00Z",
"end_datetime" => "2026-02-05T11:00:00Z"
}
admin_email = Application.get_env(:spazio_solazzo, :admin_email)
assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
emails = Memory.all()
admin_notification =
Enum.find(emails, fn email ->
email.to == [{"", admin_email}]
end)
assert admin_notification != nil
assert String.contains?(admin_notification.html_body, "Admin Test")
assert String.contains?(admin_notification.html_body, "admin.test@example.com")
end
end
end

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

@ -1,234 +1,738 @@
defmodule SpazioSolazzo.BookingSystem.BookingTest do
use ExUnit.Case, async: true
use SpazioSolazzo.DataCase
use SpazioSolazzo.DataCase
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking
alias SpazioSolazzo.BookingSystem.Booking.EmailWorker
setup do
{:ok, space} = BookingSystem.create_space("Test", "test2", "desc")
{:ok, asset} = BookingSystem.create_asset("Table 1", space.id)
{:ok, time_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[18:00:00],
:monday,
space.id
{:ok, space} =
BookingSystem.create_space(
"Test Space",
"test-space",
"Test description",
2
)
user = register_user("test@example.com")
user = register_user("testuser@example.com", "Test User")
%{space: space, asset: asset, time_slot: time_slot, user: user}
date = ~D[2026-02-10]
%{space: space, user: user, date: date}
end
test "it can create a booking from a time slot template", %{
asset: asset,
time_slot: time_slot,
user: user
} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
"John",
"john@example.com",
"+393627384027",
"test"
)
describe "create_booking" do
test "creates a booking request successfully", %{space: space, date: date} do
assert {:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
"+39 1234567890",
"Test booking"
)
assert booking.start_time == time_slot.start_time
assert booking.end_time == time_slot.end_time
assert booking.state == :reserved
assert booking.user_id == user.id
assert booking.space_id == space.id
assert booking.user_id == nil
assert booking.date == date
assert booking.start_time == ~T[09:00:00]
assert booking.end_time == ~T[10:00:00]
assert booking.customer_name == "John Doe"
assert booking.customer_email == "john@example.com"
assert booking.customer_phone == "+39 1234567890"
assert booking.customer_comment == "Test booking"
assert booking.state == :requested
end
test "creates booking with authenticated user", %{space: space, user: user, date: date} do
assert {:ok, booking} =
BookingSystem.create_booking(
space.id,
user.id,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
user.email,
"+39 1234567890",
""
)
assert booking.user_id == user.id
assert to_string(booking.customer_email) == to_string(user.email)
end
test "rejects booking with end time before start time", %{space: space, date: date} do
assert {:error, error} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[10:00:00],
~T[09:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start_time")
end
test "rejects booking in the past", %{space: space} do
past_date = Date.add(Date.utc_today(), -1)
assert {:error, error} =
BookingSystem.create_booking(
space.id,
nil,
past_date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "cannot be in the past")
end
test "allows booking for today", %{space: space} do
today = Date.utc_today()
assert {:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
assert booking.date == today
end
test "requires customer name and email", %{space: space, date: date} do
assert {:error, _error} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"",
"",
"",
""
)
end
test "phone number is optional", %{space: space, date: date} do
assert {:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
assert booking.customer_phone == nil || booking.customer_phone == ""
end
end
test "it sends a confirmation email after the booking is created", %{
asset: asset,
time_slot: time_slot,
user: user
} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
"John",
"john@example.com",
"+393627384027",
"test"
)
describe "approve_booking/1" do
test "approves a pending booking", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
formatted_date = Calendar.strftime(booking.date, "%A, %B %d")
assert booking.state == :requested
assert_enqueued worker: EmailWorker,
args: %{
"booking_id" => booking.id,
"customer_name" => booking.customer_name,
"customer_email" => booking.customer_email,
"date" => formatted_date,
"start_time" => booking.start_time,
"end_time" => booking.end_time
}
{:ok, approved_booking} = BookingSystem.approve_booking(booking.id)
assert approved_booking.state == :accepted
assert approved_booking.id == booking.id
end
test "cannot approve already approved booking", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
assert {:error, error} = BookingSystem.approve_booking(booking.id)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "no matching transition")
end
test "cannot approve cancelled booking", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
{:ok, _} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
assert {:error, error} = BookingSystem.approve_booking(booking.id)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "no matching transition")
end
end
test "it can confirm a booking was paid", %{asset: asset, time_slot: time_slot, user: user} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
"John",
"john@example.com",
"+393627384027",
"test"
)
describe "cancel_booking/1" do
test "cancels a pending booking", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
assert booking.state == :reserved
{:ok, cancelled_booking} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
assert {:ok, booking} = BookingSystem.confirm_booking(booking)
assert cancelled_booking.state == :cancelled
assert cancelled_booking.id == booking.id
end
assert booking.state == :completed
test "cancels an approved booking", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
{:ok, cancelled_booking} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
assert cancelled_booking.state == :cancelled
end
test "cannot cancel already cancelled booking", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
{:ok, _} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
assert {:error, error} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "no matching transition")
end
end
test "it can cancel a booking", %{asset: asset, time_slot: time_slot, user: user} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
"John",
"john@example.com",
"+393627384027",
"test"
)
describe "search_bookings/5 for accepted bookings" do
test "returns only approved bookings for specific date", %{space: space, date: date} do
{:ok, approved1} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
assert booking.state == :reserved
{:ok, _} = BookingSystem.approve_booking(approved1.id)
assert {:ok, booking} = BookingSystem.cancel_booking(booking)
{:ok, approved2} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[10:00:00],
~T[11:00:00],
"User 2",
"user2@example.com",
"",
""
)
assert booking.state == :cancelled
{:ok, _} = BookingSystem.approve_booking(approved2.id)
{:ok, _pending} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[11:00:00],
~T[12:00:00],
"User 3",
"user3@example.com",
"",
""
)
start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
assert length(bookings) == 2
assert Enum.all?(bookings, &(&1.state == :accepted))
end
test "does not return cancelled bookings", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
{:ok, _} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
assert bookings == []
end
test "only returns bookings for specified date", %{space: space, date: date} do
other_date = Date.add(date, 1)
{:ok, booking1} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, _} = BookingSystem.approve_booking(booking1.id)
{:ok, booking2} =
BookingSystem.create_booking(
space.id,
nil,
other_date,
~T[09:00:00],
~T[10:00:00],
"User 2",
"user2@example.com",
"",
""
)
{:ok, _} = BookingSystem.approve_booking(booking2.id)
start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
assert length(bookings) == 1
assert hd(bookings).date == date
end
test "only returns bookings for specified space", %{space: space, date: date} do
{:ok, other_space} =
BookingSystem.create_space(
"Other Space",
"other-space",
"Other description",
5
)
{:ok, booking} =
BookingSystem.create_booking(
other_space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
assert bookings == []
end
end
test "it can list asset bookings by date", %{
asset: asset,
space: space,
time_slot: time_slot,
user: user
} do
{:ok, asset2} = BookingSystem.create_asset("Table 2", space.id)
{:ok, asset3} = BookingSystem.create_asset("Table 3", space.id)
today_date = Date.utc_today()
describe "count pending requests" do
test "returns only pending bookings", %{space: space, date: date} do
{:ok, _pending1} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, time_slot2} =
BookingSystem.create_time_slot_template(~T[13:00:00], ~T[18:00:00], :tuesday, space.id)
{:ok, approved} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[10:00:00],
~T[11:00:00],
"User 2",
"user2@example.com",
"",
""
)
{:ok, time_slot3} =
BookingSystem.create_time_slot_template(~T[09:00:00], ~T[13:00:00], :tuesday, space.id)
{:ok, _} = BookingSystem.approve_booking(approved.id)
# Create the bookings we want to query
assert {:ok, _} =
BookingSystem.create_booking(
time_slot2.id,
asset.id,
user.id,
today_date,
"John",
"john@example.com",
"+393627384027",
"test"
)
{:ok, cancelled} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[11:00:00],
~T[12:00:00],
"User 3",
"user3@example.com",
"",
""
)
assert {:ok, _} =
BookingSystem.create_booking(
time_slot3.id,
asset.id,
user.id,
today_date,
"John",
"john@example.com",
"+393627384027",
"test"
)
{:ok, _} = BookingSystem.cancel_booking(cancelled.id, "Test cancellation")
# Create bookings for asset but another date
assert {:ok, _} =
BookingSystem.create_booking(
time_slot2.id,
asset.id,
user.id,
Date.add(today_date, 1),
"John",
"john@example.com",
"+393627384027",
"test"
)
{:ok, count} =
Ash.count(SpazioSolazzo.BookingSystem.Booking,
query: [filter: [state: :requested]]
)
# Create bookings for other assets
assert {:ok, _} =
BookingSystem.create_booking(
time_slot.id,
asset2.id,
user.id,
today_date,
"John",
"john@example.com",
"+393627384027",
"test"
)
assert count == 1
end
assert {:ok, _} =
BookingSystem.create_booking(
time_slot.id,
asset3.id,
user.id,
today_date,
"John",
"john@example.com",
"+393627384027",
"test"
)
test "returns zero when no pending requests", %{space: space, date: date} do
{:ok, booking} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
assert {:ok, bookings} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
{:ok, _} = BookingSystem.approve_booking(booking.id)
asset_id = asset.id
{:ok, count} =
Ash.count(SpazioSolazzo.BookingSystem.Booking,
query: [filter: [state: :requested]]
)
assert [
%Booking{date: ^today_date, asset_id: ^asset_id},
%Booking{date: ^today_date, asset_id: ^asset_id}
] = bookings
assert count == 0
end
test "counts pending requests across multiple spaces", %{space: space, date: date} do
{:ok, other_space} =
BookingSystem.create_space(
"Other Space",
"other-space-pending",
"Other description",
5
)
{:ok, _pending1} =
BookingSystem.create_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 1",
"user1@example.com",
"",
""
)
{:ok, _pending2} =
BookingSystem.create_booking(
other_space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"User 2",
"user2@example.com",
"",
""
)
{:ok, count} =
Ash.count(SpazioSolazzo.BookingSystem.Booking,
query: [filter: [state: :requested]]
)
assert count == 2
end
end
test "booking belongs to the user who created it", %{
asset: asset,
time_slot: time_slot,
user: user
} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
user.name,
user.email,
user.phone_number,
"test comment"
)
describe "create_walk_in/7" do
test "creates a walk-in booking with accepted state", %{space: space} do
start_datetime = DateTime.utc_now() |> DateTime.add(1, :hour)
end_datetime = DateTime.add(start_datetime, 2, :hour)
assert booking.user_id == user.id
assert {:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"walkin@example.com",
"+39 1234567890"
)
# Load the booking with the user relationship
{:ok, booking_with_user} = Ash.load(booking, :user, authorize?: false)
assert booking_with_user.user.id == user.id
assert booking_with_user.user.email == user.email
assert booking_with_user.user.name == user.name
assert booking.space_id == space.id
assert booking.customer_name == "Walk-in Customer"
assert booking.customer_email == "walkin@example.com"
assert booking.customer_phone == "+39 1234567890"
assert booking.state == :accepted
assert booking.date == DateTime.to_date(start_datetime)
# Compare times ignoring microseconds
expected_start = DateTime.to_time(start_datetime)
expected_end = DateTime.to_time(end_datetime)
assert booking.start_time.hour == expected_start.hour
assert booking.start_time.minute == expected_start.minute
assert booking.start_time.second == expected_start.second
assert booking.end_time.hour == expected_end.hour
assert booking.end_time.minute == expected_end.minute
assert booking.end_time.second == expected_end.second
end
test "creates walk-in without optional fields", %{space: space} do
start_datetime = DateTime.utc_now() |> DateTime.add(1, :hour)
end_datetime = DateTime.add(start_datetime, 2, :hour)
assert {:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"walkin@example.com",
nil
)
assert booking.customer_phone == nil
assert booking.customer_comment == nil
assert booking.state == :accepted
end
test "rejects walk-in with end datetime before start datetime", %{space: space} do
start_datetime = DateTime.utc_now() |> DateTime.add(2, :hour)
end_datetime = DateTime.add(start_datetime, -1, :hour)
assert {:error, error} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"walkin@example.com",
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start_datetime")
end
test "rejects walk-in with end time in the past", %{space: space} do
start_datetime = DateTime.utc_now() |> DateTime.add(-2, :hour)
end_datetime = DateTime.utc_now() |> DateTime.add(-1, :hour)
assert {:error, error} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"walkin@example.com",
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "cannot be in the past")
end
test "allows walk-in with start time in the past but end time in the future", %{space: space} do
start_datetime = DateTime.utc_now() |> DateTime.add(-1, :hour)
end_datetime = DateTime.utc_now() |> DateTime.add(2, :hour)
assert {:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"walkin@example.com",
nil
)
assert booking.state == :accepted
assert booking.customer_name == "Walk-in Customer"
end
test "rejects walk-in with invalid email", %{space: space} do
start_datetime = DateTime.utc_now() |> DateTime.add(1, :hour)
end_datetime = DateTime.add(start_datetime, 2, :hour)
assert {:error, error} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"invalid-email",
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be a valid email")
end
test "converts datetime to date and time correctly", %{space: space} do
date = Date.add(Date.utc_today(), 1)
start_time = ~T[14:30:00]
end_time = ~T[16:45:00]
start_datetime = DateTime.new!(date, start_time, "Etc/UTC")
end_datetime = DateTime.new!(date, end_time, "Etc/UTC")
assert {:ok, booking} =
BookingSystem.create_walk_in(
space.id,
start_datetime,
end_datetime,
"Walk-in Customer",
"walkin@example.com",
nil
)
assert booking.date == date
assert booking.start_time == start_time
assert booking.end_time == end_time
end
end
end

View file

@ -4,21 +4,85 @@ defmodule SpazioSolazzo.BookingSystem.SpaceTest do
alias SpazioSolazzo.BookingSystem
test "can create a space" do
assert {:ok, _} =
BookingSystem.create_space("Space", "space", "test description")
describe "create_space/4" do
test "creates a space with all attributes" do
assert {:ok, space} =
BookingSystem.create_space(
"Test Space",
"test-space",
"test description",
10
)
assert {:ok, space} = BookingSystem.get_space_by_slug("space")
assert space.name == "Test Space"
assert space.slug == "test-space"
assert space.description == "test description"
assert space.capacity == 10
end
assert space.slug == "space"
test "requires positive capacity values" do
assert {:error, error} =
BookingSystem.create_space(
"Zero Space",
"zero",
"description",
-1
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be greater than 0")
end
end
test "can't create two spaces with same name and slug" do
assert {:ok, _} = BookingSystem.create_space("Space", "space", "test description")
assert {:error, error} = BookingSystem.create_space("Space", "space", "test description")
describe "get_space_by_slug/1" do
test "retrieves space by slug" do
{:ok, _} =
BookingSystem.create_space("Space", "test-slug", "test description", 5)
error_messages = Ash.Error.error_descriptions(error)
assert {:ok, space} = BookingSystem.get_space_by_slug("test-slug")
assert String.contains?(error_messages, "has already been")
assert space.slug == "test-slug"
assert space.name == "Space"
end
test "returns error when space not found" do
assert {:error, _} = BookingSystem.get_space_by_slug("nonexistent")
end
end
describe "space uniqueness" do
test "can't create two spaces with same slug" do
assert {:ok, _} =
BookingSystem.create_space("Space 1", "same-slug", "description 1", 5)
assert {:error, error} =
BookingSystem.create_space("Space 2", "same-slug", "description 2", 10)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "has already been")
end
test "can't create two spaces with same name" do
assert {:ok, _} =
BookingSystem.create_space("Same Name", "slug-1", "description 1", 5)
assert {:error, error} =
BookingSystem.create_space("Same Name", "slug-2", "description 2", 10)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "has already been")
end
test "can create spaces with different names and slugs" do
assert {:ok, space1} =
BookingSystem.create_space("Space 1", "slug-1", "description 1", 5)
assert {:ok, space2} =
BookingSystem.create_space("Space 2", "slug-2", "description 2", 10)
assert space1.id != space2.id
end
end
end

View file

@ -5,65 +5,263 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplateTest do
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} = BookingSystem.create_space("Test", "test", "description")
{:ok, space} =
BookingSystem.create_space(
"Test Space",
"test-space",
"Test description",
10
)
%{space: space}
end
test "prevents overlapping time slot templates for same space", %{space: space} do
assert {:ok, _} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[12:00:00],
:monday,
space.id
)
describe "create_time_slot_template/4" do
test "creates a time slot template successfully", %{space: space} do
assert {:ok, slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[10:00:00],
:monday,
space.id
)
assert {:error, changeset} =
BookingSystem.create_time_slot_template(
~T[11:00:00],
~T[13:00:00],
:monday,
space.id
)
assert slot.start_time == ~T[09:00:00]
assert slot.end_time == ~T[10:00:00]
assert slot.day_of_week == :monday
assert slot.space_id == space.id
end
assert Ash.Error.error_descriptions(changeset.errors) =~ "overlaps"
test "creates templates for all days of the week", %{space: space} do
days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
for day <- days do
assert {:ok, slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[10:00:00],
day,
space.id
)
assert slot.day_of_week == day
end
end
test "prevents overlapping time slots on same day", %{space: space} do
assert {:ok, _slot1} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[11:00:00],
:monday,
space.id
)
assert {:error, error} =
BookingSystem.create_time_slot_template(
~T[10:00:00],
~T[12:00:00],
:monday,
space.id
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(
error_messages,
"time slot overlaps with existing template for this space and day."
)
end
test "allows same time slot on different days", %{space: space} do
assert {:ok, _slot1} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[11:00:00],
:monday,
space.id
)
assert {:ok, slot2} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[11:00:00],
:tuesday,
space.id
)
assert slot2.day_of_week == :tuesday
end
test "allows adjacent time slots on same day", %{space: space} do
assert {:ok, _slot1} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[11:00:00],
:monday,
space.id
)
assert {:ok, slot2} =
BookingSystem.create_time_slot_template(
~T[11:00:00],
~T[13:00:00],
:monday,
space.id
)
assert slot2.start_time == ~T[11:00:00]
end
test "rejects invalid day of week", %{space: space} do
assert {:error, error} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[10:00:00],
:invalid_day,
space.id
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "atom must be one of")
end
test "rejects end time before start time", %{space: space} do
assert {:error, error} =
BookingSystem.create_time_slot_template(
~T[10:00:00],
~T[09:00:00],
:monday,
space.id
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start_time")
end
test "rejects equal start and end times", %{space: space} do
assert {:error, error} =
BookingSystem.create_time_slot_template(
~T[10:00:00],
~T[10:00:00],
:monday,
space.id
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start_time")
end
end
test "allows non-overlapping time slot templates for same space on the same day", %{
space: space
} do
assert {:ok, _} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[12:00:00],
:monday,
space.id
)
describe "get_space_time_slots_by_date/2" do
setup %{space: space} do
{:ok, monday_morning} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[13:00:00],
:monday,
space.id
)
assert {:ok, _} =
BookingSystem.create_time_slot_template(
~T[13:00:00],
~T[16:00:00],
:monday,
space.id
)
end
{:ok, monday_afternoon} =
BookingSystem.create_time_slot_template(
~T[14:00:00],
~T[18:00:00],
:monday,
space.id
)
test "allows overlapping time slot templates for same space on different days", %{space: space} do
assert {:ok, _} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[12:00:00],
:monday,
space.id
)
{:ok, tuesday_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[13:00:00],
:tuesday,
space.id
)
assert {:ok, _} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[12:00:00],
:tuesday,
space.id
)
%{
monday_morning: monday_morning,
monday_afternoon: monday_afternoon,
tuesday_slot: tuesday_slot
}
end
test "returns time slots for specific date's day of week", %{space: space} do
monday_date = ~D[2026-02-02]
assert Date.day_of_week(monday_date) == 1
{:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, monday_date)
assert length(slots) == 2
assert Enum.any?(slots, &(&1.start_time == ~T[09:00:00]))
assert Enum.any?(slots, &(&1.start_time == ~T[14:00:00]))
end
test "returns different slots for different days", %{space: space} do
tuesday_date = ~D[2026-02-03]
assert Date.day_of_week(tuesday_date) == 2
{:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, tuesday_date)
assert length(slots) == 1
assert hd(slots).start_time == ~T[09:00:00]
end
test "returns empty list when no slots for that day", %{space: space} do
wednesday_date = ~D[2026-02-04]
assert Date.day_of_week(wednesday_date) == 3
{:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, wednesday_date)
assert slots == []
end
test "handles all days of the week correctly", %{space: space} do
days_and_dates = [
{:monday, ~D[2026-02-02]},
{:tuesday, ~D[2026-02-03]},
{:wednesday, ~D[2026-02-04]},
{:thursday, ~D[2026-02-05]},
{:friday, ~D[2026-02-06]},
{:saturday, ~D[2026-02-07]},
{:sunday, ~D[2026-02-01]}
]
for {day_atom, date} <- days_and_dates do
BookingSystem.create_time_slot_template(
~T[20:00:00],
~T[21:00:00],
day_atom,
space.id
)
{:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, date)
assert Enum.any?(slots, &(&1.start_time == ~T[20:00:00]))
end
end
test "only returns slots for specified space", %{space: space} do
{:ok, other_space} =
BookingSystem.create_space(
"Other Space",
"other-space",
"Other description",
5
)
{:ok, _other_slot} =
BookingSystem.create_time_slot_template(
~T[20:00:00],
~T[22:00:00],
:monday,
other_space.id
)
monday_date = ~D[2026-02-02]
{:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, monday_date)
assert Enum.all?(slots, &(&1.space_id == space.id))
refute Enum.any?(slots, &(&1.start_time == ~T[20:00:00]))
end
end
end

View file

@ -1,99 +0,0 @@
defmodule SpazioSolazzoWeb.BookingControllerTest do
use SpazioSolazzoWeb.ConnCase, async: true
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking.Token
setup do
unique_id = :erlang.unique_integer([:positive, :monotonic])
{:ok, space} =
BookingSystem.create_space("Test #{unique_id}", "test-space-#{unique_id}", "desc")
{:ok, asset} = BookingSystem.create_asset("Table 1", space.id)
{:ok, time_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[18:00:00],
:monday,
space.id
)
user = register_user("test@example.com", "Test User", "+1234567890")
%{space: space, asset: asset, time_slot: time_slot, user: user}
end
describe "cancel/2" do
test "first cancel shows success message, not error message", %{
conn: conn,
asset: asset,
time_slot: time_slot,
user: user
} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
"John",
"john@example.com",
"+393627384027",
"test"
)
# Verify initial state
assert booking.state == :reserved
cancel_token = Token.generate_customer_cancel_token(booking.id)
conn = get(conn, ~p"/bookings/cancel?token=#{cancel_token}")
assert redirected_to(conn) == "/"
# Should show success message
assert Phoenix.Flash.get(conn.assigns.flash, :info) == "The booking has been cancelled."
# Should NOT show error message
assert Phoenix.Flash.get(conn.assigns.flash, :error) == nil
# Verify booking is now cancelled in database
cancelled_booking = Ash.get!(SpazioSolazzo.BookingSystem.Booking, booking.id)
assert cancelled_booking.state == :cancelled
end
test "shows error message when booking is already cancelled", %{
conn: conn,
asset: asset,
time_slot: time_slot,
user: user
} do
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
user.id,
Date.utc_today(),
"John",
"john@example.com",
"+393627384027",
"test"
)
# Cancel the booking first time
{:ok, _cancelled_booking} = BookingSystem.cancel_booking(booking)
# Generate a cancel token for the already-cancelled booking
cancel_token = Token.generate_customer_cancel_token(booking.id)
# Try to cancel again
conn = get(conn, ~p"/bookings/cancel?token=#{cancel_token}")
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Action could not be completed (e.g. already processed)."
end
end
end

View file

@ -0,0 +1,573 @@
defmodule SpazioSolazzoWeb.Admin.BookingManagementPaginationTest do
use SpazioSolazzoWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking-pagination",
"Coworking space",
10
)
admin_user =
"admin@example.com"
|> register_user("Admin User")
|> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
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?history_page=1&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?history_page=1&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&pending_page=1")
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" => space.slug})
|> 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

@ -0,0 +1,279 @@
defmodule SpazioSolazzoWeb.Admin.BookingManagementRejectionTest do
use SpazioSolazzoWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
defp create_booking(space, user) do
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"test@example.com",
"+1234567890",
"Test booking comment"
)
booking
end
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking",
"Coworking space",
5
)
admin_user =
"admin@example.com"
|> register_user("Admin User")
|> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
regular_user = register_user("user@example.com", "Regular User")
%{space: space, admin_user: admin_user, regular_user: regular_user}
end
describe "booking rejection modal" do
test "shows reject modal when clicking reject button", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
refute has_element?(view, "#success-modal")
html =
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
assert html =~ "Reject Booking"
assert html =~ "Rejection Reason"
end
test "hides reject modal when clicking cancel", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
html =
view
|> element("button[phx-click='hide_reject_modal']")
|> render_click()
refute html =~ "Reject Booking"
end
test "shows error when rejection reason is empty", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
html =
view
|> element("form[phx-submit='confirm_reject']")
|> render_submit(%{"reason" => ""})
assert html =~ "Please provide a rejection reason"
end
test "successfully rejects booking with valid reason", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
view
|> element("textarea[name='reason']")
|> render_change(%{"reason" => "Space under maintenance"})
html =
view
|> element("form[phx-submit='confirm_reject']")
|> render_submit()
assert html =~ "Booking rejected"
{:ok, updated_booking} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking.id)
assert updated_booking.state == :rejected
assert updated_booking.rejection_reason == "Space under maintenance"
end
test "updates rejection reason as user types", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
html =
view
|> element("textarea[name='reason']")
|> render_change(%{"reason" => "Fully booked"})
assert html =~ "Fully booked"
end
test "closes modal after successful rejection", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
view
|> element("textarea[name='reason']")
|> render_change(%{"reason" => "Not available"})
html =
view
|> element("form[phx-submit='confirm_reject']")
|> render_submit()
refute html =~ "Reject Booking"
assert html =~ "Booking rejected"
end
test "rejected booking moves from pending to past bookings", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
booking = create_booking(space, regular_user)
conn = log_in_user(conn, admin_user)
{:ok, view, html} = live(conn, "/admin/bookings")
assert html =~ "Pending"
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
|> render_click()
view
|> element("textarea[name='reason']")
|> render_change(%{"reason" => "Not available"})
_html =
view
|> element("form[phx-submit='confirm_reject']")
|> render_submit()
{:ok, updated_booking} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking.id)
assert updated_booking.state == :rejected
assert updated_booking.rejection_reason == "Not available"
end
test "multiple bookings can be rejected independently", %{
conn: conn,
admin_user: admin_user,
space: space,
regular_user: regular_user
} do
tomorrow = Date.add(Date.utc_today(), 1)
{:ok, booking1} =
BookingSystem.create_booking(
space.id,
regular_user.id,
tomorrow,
~T[09:00:00],
~T[11:00:00],
"Test User",
"test@example.com",
"+1234567890",
"First booking"
)
{:ok, booking2} =
BookingSystem.create_booking(
space.id,
regular_user.id,
tomorrow,
~T[14:00:00],
~T[16:00:00],
"Test User",
"test@example.com",
"+1234567890",
"Second booking"
)
conn = log_in_user(conn, admin_user)
{:ok, view, _html} = live(conn, "/admin/bookings")
view
|> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking1.id}']")
|> render_click()
view
|> element("textarea[name='reason']")
|> render_change(%{"reason" => "Reason 1"})
view
|> element("form[phx-submit='confirm_reject']")
|> render_submit()
{:ok, updated_booking1} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking1.id)
{:ok, updated_booking2} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking2.id)
assert updated_booking1.state == :rejected
assert updated_booking1.rejection_reason == "Reason 1"
assert updated_booking2.state == :requested
end
end
end

View file

@ -0,0 +1,64 @@
defmodule SpazioSolazzoWeb.Admin.WalkInLiveSimpleTest do
use SpazioSolazzoWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking",
"Coworking space",
5
)
user =
"admin@example.com"
|> register_user("Admin User")
|> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
%{space: space, user: user}
end
describe "walk-in booking creation bug" do
test "can create booking by directly setting assigns", %{conn: conn, user: user, space: space} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
tomorrow = Date.add(Date.utc_today(), 1)
# Simulate the calendar component sending the date_selected message
send(view.pid, {:date_selected, tomorrow, tomorrow})
# Fill in customer details using the form
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "John Doe",
"customer_email" => "john@example.com"
})
|> render_change()
# Try to create the booking
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Walk-in booking created successfully"
# Verify booking was created
start_datetime = DateTime.new!(tomorrow, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(tomorrow, 1), ~T[00:00:00], "Etc/UTC")
assert {:ok, [_booking]} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
end
end
end

View file

@ -0,0 +1,279 @@
defmodule SpazioSolazzoWeb.Admin.WalkInLiveTest do
use SpazioSolazzoWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking",
"Coworking space",
5
)
user =
"admin@example.com"
|> register_user("Admin User")
|> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
%{space: space, user: user}
end
describe "walk-in booking form" do
test "displays the form with calendar and customer details", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
assert has_element?(view, "form[phx-change='validate_customer_details']")
assert has_element?(view, "input[name='customer_name']")
assert has_element?(view, "input[name='customer_email']")
assert has_element?(view, "button[type='submit']")
end
test "creates single-day walk-in booking successfully", %{
conn: conn,
user: user,
space: space
} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
tomorrow = Date.add(Date.utc_today(), 1)
# Simulate date selection by sending message to LiveView
send(view.pid, {:date_selected, tomorrow, tomorrow})
:timer.sleep(50)
# Fill in customer details
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "John Doe",
"customer_email" => "john@example.com"
})
|> render_change()
# Submit the form
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Walk-in booking created successfully"
# Verify booking was created
start_datetime = DateTime.new!(tomorrow, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(tomorrow, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
assert length(bookings) == 1
booking = hd(bookings)
assert booking.customer_name == "John Doe"
assert booking.customer_email == "john@example.com"
assert booking.state == :accepted
end
test "shows error when no date is selected", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
# Fill in customer details without selecting a date
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "John Doe",
"customer_email" => "john@example.com"
})
|> render_change()
# Try to submit
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Please fill in all required fields and select a date"
end
test "shows error when customer name is missing", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
tomorrow = Date.add(Date.utc_today(), 1)
send(view.pid, {:date_selected, tomorrow, tomorrow})
:timer.sleep(50)
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_email" => "john@example.com"
})
|> render_change()
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Please fill in all required fields and select a date"
end
test "shows error when customer email is missing", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
tomorrow = Date.add(Date.utc_today(), 1)
send(view.pid, {:date_selected, tomorrow, tomorrow})
:timer.sleep(50)
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "John Doe"
})
|> render_change()
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Please fill in all required fields and select a date"
end
test "creates multi-day walk-in booking", %{conn: conn, user: user, space: space} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
# Select date range (3 days)
start_date = Date.add(Date.utc_today(), 1)
end_date = Date.add(Date.utc_today(), 3)
send(view.pid, {:date_selected, start_date, end_date})
:timer.sleep(50)
# Fill in customer details
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "Jane Smith",
"customer_email" => "jane@example.com"
})
|> render_change()
# Submit the form
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Walk-in booking created successfully"
# Verify booking was created and spans multiple days
start_datetime = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC")
end_datetime_search = DateTime.new!(Date.add(start_date, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime_search,
[:accepted],
nil
)
assert length(bookings) == 1
booking = hd(bookings)
assert booking.customer_name == "Jane Smith"
# Verify booking appears on all days in the range
day2_start = DateTime.new!(Date.add(start_date, 1), ~T[00:00:00], "Etc/UTC")
day2_end = DateTime.new!(Date.add(start_date, 2), ~T[00:00:00], "Etc/UTC")
{:ok, day2_bookings} =
BookingSystem.search_bookings(space.id, day2_start, day2_end, [:accepted], nil)
assert length(day2_bookings) == 1
day3_start = DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")
day3_end = DateTime.new!(Date.add(end_date, 1), ~T[00:00:00], "Etc/UTC")
{:ok, day3_bookings} =
BookingSystem.search_bookings(space.id, day3_start, day3_end, [:accepted], nil)
assert length(day3_bookings) == 1
end
test "includes optional phone", %{conn: conn, user: user, space: space} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
tomorrow = Date.add(Date.utc_today(), 1)
send(view.pid, {:date_selected, tomorrow, tomorrow})
:timer.sleep(50)
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "John Doe",
"customer_email" => "john@example.com",
"customer_phone" => "+39 1234567890"
})
|> render_change()
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Walk-in booking created successfully"
start_datetime = DateTime.new!(tomorrow, ~T[00:00:00], "Etc/UTC")
end_datetime = DateTime.new!(Date.add(tomorrow, 1), ~T[00:00:00], "Etc/UTC")
{:ok, bookings} =
BookingSystem.search_bookings(
space.id,
start_datetime,
end_datetime,
[:accepted],
nil
)
booking = hd(bookings)
assert booking.customer_phone == "+39 1234567890"
end
test "clears form after successful booking", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/admin/walk-in")
tomorrow = Date.add(Date.utc_today(), 1)
send(view.pid, {:date_selected, tomorrow, tomorrow})
:timer.sleep(50)
view
|> form("form[phx-change='validate_customer_details']", %{
"customer_name" => "John Doe",
"customer_email" => "john@example.com"
})
|> render_change()
html =
view
|> element("form[phx-submit='create_booking']")
|> render_submit()
assert html =~ "Walk-in booking created successfully"
# Check that form inputs are cleared by verifying empty values
html = render(view)
assert html =~ "Not selected"
assert html =~ ~s(value="")
end
end
end

View file

@ -1,323 +0,0 @@
defmodule SpazioSolazzoWeb.BookingLive.AssetBookingTest do
use SpazioSolazzoWeb.ConnCase
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
setup %{conn: conn} do
{:ok, space} = BookingSystem.create_space("TestSpace", "test-space", "Test description")
{:ok, asset} = BookingSystem.create_asset("Test Asset", space.id)
today = Date.utc_today()
day_of_week = SpazioSolazzo.DateExt.day_of_week_atom(today)
{:ok, slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[10:00:00],
day_of_week,
space.id
)
user = register_user("test@example.com", "Test User", "+1234567890")
conn = log_in_user(conn, user)
%{space: space, asset: asset, slot: slot, conn: conn}
end
describe "AssetBooking mount" do
test "renders asset booking page with available time slots", %{
conn: conn,
space: space,
asset: asset
} do
{:ok, view, html} = live(conn, ~p"/book/asset/#{asset.id}")
assert html =~ space.name
assert html =~ asset.name
assert html =~ "Available Time Slots"
assert has_element?(view, "button[phx-click='select_slot']")
assert has_element?(view, "#booking-calendar")
end
test "displays calendar with current month", %{conn: conn, asset: asset} do
{:ok, view, html} = live(conn, ~p"/book/asset/#{asset.id}")
today = Date.utc_today()
month_name = Calendar.strftime(today, "%B %Y")
assert html =~ month_name
assert has_element?(view, ".calendar-container")
end
test "displays back button to space landing page", %{conn: conn, asset: asset, space: space} do
{:ok, _view, html} = live(conn, ~p"/book/asset/#{asset.id}")
assert html =~ "Back to #{space.name}"
assert html =~ "/#{space.slug}"
end
test "redirects when asset not found", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/", flash: %{"error" => "Asset not found"}}}} =
live(conn, ~p"/book/asset/00000000-0000-0000-0000-000000000000")
end
end
describe "AssetBooking time slot selection" do
test "opens booking modal when clicking a time slot", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
assert has_element?(view, "textarea[name='customer_comment']")
end
end
describe "AssetBooking full booking flow" do
test "completes full booking flow", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "Test User",
"customer_phone" => "+1234567890",
"customer_comment" => "test comment"
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_email == "test@example.com"
assert booking.customer_name == "Test User"
assert booking.customer_phone == "+1234567890"
assert booking.customer_comment == "test comment"
end
end
describe "AssetBooking cancellation" do
test "cancels booking flow", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
view
|> element("button", "Cancel")
|> render_click()
refute has_element?(view, "#booking-form")
end
end
describe "AssetBooking date selection" do
test "updates available time slots when selecting date from calendar", %{
conn: conn,
asset: asset,
space: space
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
tomorrow = Date.add(Date.utc_today(), 1)
tomorrow_day_of_week = SpazioSolazzo.DateExt.day_of_week_atom(tomorrow)
{:ok, _slot} =
BookingSystem.create_time_slot_template(
~T[14:00:00],
~T[15:00:00],
tomorrow_day_of_week,
space.id
)
# Click on a date in the calendar
view
|> element(
"#booking-calendar button[phx-click='select-date'][phx-value-date='#{Date.to_iso8601(tomorrow)}']"
)
|> render_click()
assert has_element?(view, "button[phx-click='select_slot']")
end
test "prevents selection of past dates", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
yesterday = Date.add(Date.utc_today(), -1)
# Past dates should be disabled
assert has_element?(
view,
"#booking-calendar button[disabled][phx-value-date='#{Date.to_iso8601(yesterday)}']"
)
end
test "displays selected date in the time slots section", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
today = Date.utc_today()
formatted_date = SpazioSolazzo.CalendarExt.format_date(today)
assert has_element?(view, ".time-slots-wrapper", formatted_date)
end
end
describe "AssetBooking calendar navigation" do
test "navigates to next month", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
current_month = Date.utc_today() |> Date.beginning_of_month()
next_month = Date.shift(current_month, month: 1)
next_month_name = Calendar.strftime(next_month, "%B %Y")
view
|> element("#booking-calendar button[phx-click='next-month']")
|> render_click()
assert has_element?(view, ".calendar-container", next_month_name)
end
test "navigates to previous month", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
current_month = Date.utc_today() |> Date.beginning_of_month()
prev_month = Date.shift(current_month, month: -1)
prev_month_name = Calendar.strftime(prev_month, "%B %Y")
view
|> element("#booking-calendar button[phx-click='prev-month']")
|> render_click()
assert has_element?(view, ".calendar-container", prev_month_name)
end
test "only displays days from current viewing month", %{conn: conn, asset: asset} do
{:ok, _view, html} = live(conn, ~p"/book/asset/#{asset.id}")
# Calendar should have empty divs for days not in current month
assert html =~ ~s(<div class="p-2"></div>)
end
end
describe "AssetBooking without phone number" do
setup %{conn: conn} do
# Create a separate connection with a user without phone number
user = register_user("nophone@example.com", "User Without Phone", nil)
conn = log_in_user(conn, user)
%{conn: conn}
end
test "user without phone number can view booking form", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
assert has_element?(view, "input[name='customer_name']")
assert has_element?(view, "input[name='customer_phone']")
assert has_element?(view, "textarea[name='customer_comment']")
end
test "user without phone number can create booking without providing phone", %{
conn: conn,
asset: asset
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
# Submit booking with name but no phone
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "User Without Phone",
"customer_phone" => "",
"customer_comment" => "test comment"
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_email == "nophone@example.com"
assert booking.customer_name == "User Without Phone"
assert booking.customer_phone == nil or booking.customer_phone == ""
assert booking.customer_comment == "test comment"
end
test "user without phone number can edit name in booking form", %{
conn: conn,
asset: asset
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
# Change the name
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "Different Name",
"customer_phone" => "",
"customer_comment" => ""
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_name == "Different Name"
end
test "user without phone number can optionally add phone during booking", %{
conn: conn,
asset: asset
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
# Add phone number during booking
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "User Without Phone",
"customer_phone" => "+39 123 456 789",
"customer_comment" => ""
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_phone == "+39 123 456 789"
end
end
end

View file

@ -0,0 +1,680 @@
defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
use SpazioSolazzoWeb.ConnCase
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
# Helper to convert old map-based call to new signature
defp request_booking(space_id, user_id, date, start_time, end_time, customer_details) do
BookingSystem.create_booking(
space_id,
user_id,
date,
start_time,
end_time,
customer_details.name,
customer_details.email,
customer_details[:phone],
customer_details[:comment]
)
end
setup %{conn: conn} do
{:ok, space} =
BookingSystem.create_space(
"Test Space",
"test-space",
"Test description",
2
)
today = Date.utc_today()
day_of_week = day_of_week_atom(today)
{:ok, slot1} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[13:00:00],
day_of_week,
space.id
)
{:ok, slot2} =
BookingSystem.create_time_slot_template(
~T[14:00:00],
~T[18:00:00],
day_of_week,
space.id
)
user = register_user("testuser@example.com", "Test User", "+39 1234567890")
unauth_conn = conn
conn = log_in_user(conn, user)
%{
conn: conn,
unauth_conn: unauth_conn,
space: space,
slot1: slot1,
slot2: slot2,
today: today,
user: user
}
end
defp day_of_week_atom(date) do
case Date.day_of_week(date) do
1 -> :monday
2 -> :tuesday
3 -> :wednesday
4 -> :thursday
5 -> :friday
6 -> :saturday
7 -> :sunday
end
end
describe "SpaceBooking mount" do
test "renders space booking page with available time slots", %{
conn: conn,
space: space
} do
{:ok, view, html} = live(conn, ~p"/book/space/#{space.slug}")
assert html =~ space.name
assert html =~ space.description
assert html =~ "Available Time Slots"
assert has_element?(view, "button[phx-click='select_slot']")
assert has_element?(view, "#booking-calendar")
end
test "displays calendar with current month", %{conn: conn, space: space} do
{:ok, view, html} = live(conn, ~p"/book/space/#{space.slug}")
today = Date.utc_today()
month_name = Calendar.strftime(today, "%B %Y")
assert html =~ month_name
assert has_element?(view, ".calendar-container")
end
test "displays back button to home page", %{conn: conn, space: space} do
{:ok, _view, html} = live(conn, ~p"/book/space/#{space.slug}")
assert html =~ "Back to #{space.name}"
assert html =~ "/#{space.slug}"
end
test "redirects when space not found", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/", flash: %{"error" => "Space not found"}}}} =
live(conn, ~p"/book/space/nonexistent-space")
end
test "shows message when no time slots available", %{conn: conn} do
{:ok, empty_space} =
BookingSystem.create_space(
"Empty Space",
"empty-space",
"No slots",
5
)
{:ok, _view, html} = live(conn, ~p"/book/space/#{empty_space.slug}")
assert html =~ "No time slots available for this date"
end
end
describe "SpaceBooking calendar navigation" do
test "navigates to previous month", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
today = Date.utc_today()
current_month = Calendar.strftime(today, "%B %Y")
prev_month =
today
|> Date.shift(month: -1)
|> Calendar.strftime("%B %Y")
html = view |> element("button[phx-click='prev-month']") |> render_click()
assert html =~ prev_month
refute html =~ current_month
end
test "navigates to next month", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
today = Date.utc_today()
current_month = Calendar.strftime(today, "%B %Y")
next_month =
today
|> Date.shift(month: 1)
|> Calendar.strftime("%B %Y")
html = view |> element("button[phx-click='next-month']") |> render_click()
assert html =~ next_month
refute html =~ current_month
end
end
describe "SpaceBooking date selection" do
test "updates time slots when selecting a different date", %{conn: conn, space: space} do
# 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(
~T[20:00:00],
~T[22:00:00],
:monday,
space.id
)
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
# Click on a date button in the calendar
view
|> element("button[phx-value-date='#{Date.to_iso8601(monday_date)}']")
|> render_click()
html = render(view)
assert html =~ "20:00"
assert html =~ "22:00"
end
test "shows selected date in formatted string", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
today = Date.utc_today()
formatted_date = Calendar.strftime(today, "%A, %B %d, %Y")
html = render(view)
assert html =~ formatted_date
end
test "does not allow selecting past dates", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
# Navigate to previous month (January 2026), where all dates are in the past
view
|> element("button[phx-click='prev-month']")
|> render_click()
# Check that there's at least one disabled button (past date)
html = render(view)
assert html =~ "disabled"
# Verify that clicking a past date doesn't change the selected date
# by checking we can't click on January 15th (a past date)
refute has_element?(view, "button[phx-click='select-date'][phx-value-date='2026-01-15']")
end
end
describe "SpaceBooking time slot display" do
test "displays available time slots with correct styling", %{conn: conn, space: space} do
{:ok, _view, html} = live(conn, ~p"/book/space/#{space.slug}")
assert html =~ "09:00"
assert html =~ "13:00"
assert html =~ "14:00"
assert html =~ "18:00"
assert html =~ "Available - Request Booking"
end
test "shows high demand label for slots over public capacity", %{
conn: conn,
space: space,
today: today
} do
for i <- 1..2 do
{:ok, booking} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User #{i}", email: "user#{i}@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
end
{:ok, _view, html} = live(conn, ~p"/book/space/#{space.slug}")
assert html =~ "High Demand - Join Waitlist"
end
test "shows slots over capacity with high demand warning", %{
conn: conn,
space: space,
today: today
} do
for i <- 1..3 do
{:ok, booking} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User #{i}", email: "user#{i}@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
end
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
html = render(view)
assert html =~ "14:00"
assert html =~ "09:00"
assert html =~ "High Demand"
end
end
describe "SpaceBooking modal interaction" do
test "opens booking modal when clicking a time slot", %{
conn: conn,
space: space,
slot1: slot1,
user: user
} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
assert has_element?(view, "#booking-modal")
assert has_element?(view, "input[name='customer_name']")
# Authenticated users see their email displayed, not an input field
assert render(view) =~ to_string(user.email)
assert has_element?(view, "input[name='customer_phone']")
assert has_element?(view, "textarea[name='customer_comment']")
end
test "closes modal when clicking cancel", %{conn: conn, space: space, slot1: slot1} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
assert has_element?(view, "#booking-modal")
view
|> element("button", "Cancel")
|> render_click()
refute has_element?(view, "#booking-modal")
end
test "shows high demand warning in modal for slots over public capacity", %{
conn: conn,
space: space,
today: today,
slot1: slot1
} do
for i <- 1..2 do
{:ok, booking} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User #{i}", email: "user#{i}@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
end
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
html =
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
assert html =~ "High Demand Time Slot"
assert html =~ "subject to admin approval"
end
end
describe "SpaceBooking submission" do
test "completes booking request successfully", %{conn: conn, space: space, slot1: slot1} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
form_data = %{
"customer_name" => "Test User",
"customer_email" => "testuser@example.com",
"customer_phone" => "+39 1234567890",
"customer_comment" => "Test comment"
}
view
|> element("#booking-form")
|> render_submit(form_data)
assert has_element?(view, "#success-modal")
assert render(view) =~ "Request Submitted!"
assert render(view) =~ "pending approval"
end
test "creates booking in database", %{conn: conn, space: space, today: today, slot1: slot1} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
form_data = %{
"customer_name" => "Test User",
"customer_email" => "testuser@example.com",
"customer_phone" => "+39 1234567890",
"customer_comment" => "Test comment"
}
view
|> element("#booking-form")
|> render_submit(form_data)
# Process the handle_info message that creates the booking
render(view)
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)
assert booking.customer_name == "Test User"
assert booking.customer_email == "testuser@example.com"
assert booking.customer_phone == "+39 1234567890"
assert booking.customer_comment == "Test comment"
assert booking.state == :requested
end
test "validates required fields", %{conn: conn, space: space, slot1: slot1} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
form_data = %{
"customer_name" => "",
"customer_email" => "",
"customer_phone" => "",
"customer_comment" => ""
}
view
|> element("#booking-form")
|> render_submit(form_data)
refute has_element?(view, "#success-modal")
end
test "closes success modal when clicking close", %{conn: conn, space: space, slot1: slot1} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
form_data = %{
"customer_name" => "Test User",
"customer_email" => "testuser@example.com",
"customer_phone" => "",
"customer_comment" => ""
}
view
|> element("#booking-form")
|> render_submit(form_data)
assert has_element?(view, "#success-modal")
view
|> element("button[phx-click='close_success_modal']")
|> render_click()
refute has_element?(view, "#success-modal")
end
end
describe "SpaceBooking real-time updates" do
test "updates availability when booking is approved", %{
conn: conn,
space: space,
today: today
} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
initial_html = render(view)
assert initial_html =~ "Available - Request Booking"
{:ok, booking} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User 1", email: "user1@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
html = render(view)
assert html =~ "Available - Request Booking"
{:ok, booking2} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User 2", email: "user2@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking2.id)
html = render(view)
assert html =~ "High Demand - Join Waitlist"
end
test "updates when booking is cancelled", %{conn: conn, space: space, today: today} do
{:ok, booking1} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User 1", email: "user1@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking1.id)
{:ok, booking2} =
request_booking(
space.id,
nil,
today,
~T[09:00:00],
~T[13:00:00],
%{name: "User 2", email: "user2@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking2.id)
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
html = render(view)
assert html =~ "High Demand - Join Waitlist"
{:ok, _} = BookingSystem.cancel_booking(booking1.id, "Test cancellation")
html = render(view)
assert html =~ "Available - Request Booking"
end
end
describe "SpaceBooking authenticated user flow" do
test "pre-fills user data in booking form", %{
conn: conn,
space: space,
user: user,
slot1: slot1
} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
html =
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
assert html =~ user.name
assert html =~ to_string(user.email)
assert html =~ user.phone_number
end
test "uses authenticated user email for booking", %{
slot1: slot1,
conn: conn,
space: space,
user: user,
today: today
} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
view
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
form_data = %{
"customer_name" => "Test User",
"customer_phone" => "+39 1234567890",
"customer_comment" => "Test"
}
view
|> element("#booking-form")
|> render_submit(form_data)
# Process the handle_info message that creates the booking
render(view)
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)
assert to_string(booking.customer_email) == to_string(user.email)
assert booking.user_id == user.id
end
end
describe "SpaceBooking edge cases" do
# Note: This test was removed because duplicate booking prevention now correctly
# blocks the same user from booking the same slot twice, which is the expected behavior.
# Duplicate booking prevention is thoroughly tested in duplicate_booking_prevention_test.exs
test "shows high demand when public capacity is reached", %{conn: conn} do
{:ok, small_space} =
BookingSystem.create_space(
"Small Space",
"small-space",
"Limited capacity",
1
)
today = Date.utc_today()
day_of_week = day_of_week_atom(today)
{:ok, _slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
~T[10:00:00],
day_of_week,
small_space.id
)
{:ok, booking} =
request_booking(
small_space.id,
nil,
today,
~T[09:00:00],
~T[10:00:00],
%{name: "User 1", email: "user1@example.com", phone: "", comment: ""}
)
{:ok, _} = BookingSystem.approve_booking(booking.id)
{:ok, _view, html} = live(conn, ~p"/book/space/#{small_space.slug}")
assert html =~ "High Demand - Join Waitlist"
end
test "handles rapid date changes", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
# 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
|> element("button[phx-click='select-date'][phx-value-date='#{Date.to_iso8601(date)}']")
|> render_click()
end
html = render(view)
# 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

View file

@ -5,11 +5,9 @@ defmodule SpazioSolazzoWeb.CoworkingLiveTest do
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} = BookingSystem.create_space("CoworkingTest", "coworking", "desc")
{:ok, asset1} = BookingSystem.create_asset("Table 1", space.id)
{:ok, asset2} = BookingSystem.create_asset("Table 2", space.id)
{:ok, space} = BookingSystem.create_space("CoworkingTest", "coworking", "desc", 10)
%{space: space, assets: [asset1, asset2]}
%{space: space}
end
describe "CoworkingLive landing page" do
@ -17,21 +15,13 @@ defmodule SpazioSolazzoWeb.CoworkingLiveTest do
{:ok, _view, html} = live(conn, "/coworking")
assert html =~ space.name
assert html =~ "Interactive Floor Plan"
assert html =~ "Fiber Internet"
end
test "displays all available assets as selectable cards", %{
conn: conn,
assets: [asset1, asset2]
} do
{:ok, view, html} = live(conn, "/coworking")
test "has link to space booking page with correct space slug", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, "/coworking")
assert html =~ asset1.name
assert html =~ asset2.name
assert has_element?(view, "a[href='/book/asset/#{asset1.id}']")
assert has_element?(view, "a[href='/book/asset/#{asset2.id}']")
assert has_element?(view, "a[href='/book/space/#{space.slug}']")
end
end
end

View file

@ -5,10 +5,9 @@ defmodule SpazioSolazzoWeb.MeetingLiveTest do
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} = BookingSystem.create_space("MeetingTest", "meeting", "desc")
{:ok, asset} = BookingSystem.create_asset("Main Room", space.id)
{:ok, space} = BookingSystem.create_space("MeetingTest", "meeting", "desc", 1)
%{space: space, asset: asset}
%{space: space}
end
describe "MeetingLive landing page" do
@ -16,17 +15,15 @@ defmodule SpazioSolazzoWeb.MeetingLiveTest do
conn: conn,
space: space
} do
{:ok, view, html} = live(conn, "/meeting")
{:ok, _view, html} = live(conn, "/meeting")
assert html =~ space.name
assert html =~ "Book This Room"
assert has_element?(view, "h2", "Everything you need to succeed")
end
test "has link to asset booking page with correct asset id", %{conn: conn, asset: asset} do
test "has link to space booking page with correct space slug", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, "/meeting")
assert has_element?(view, "a[href='/book/asset/#{asset.id}']", "Book This Room")
assert has_element?(view, "a[href='/book/space/#{space.slug}']")
end
end
end

View file

@ -5,10 +5,9 @@ defmodule SpazioSolazzoWeb.MusicLiveTest do
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} = BookingSystem.create_space("MusicTest", "music", "desc")
{:ok, asset} = BookingSystem.create_asset("Studio", space.id)
{:ok, space} = BookingSystem.create_space("MusicTest", "music", "desc", 1)
%{space: space, asset: asset}
%{space: space}
end
describe "MusicLive landing page" do
@ -16,17 +15,15 @@ defmodule SpazioSolazzoWeb.MusicLiveTest do
conn: conn,
space: space
} do
{:ok, view, html} = live(conn, "/music")
{:ok, _view, html} = live(conn, "/music")
assert html =~ space.name
assert html =~ "Book This Room"
assert has_element?(view, "h2", "Jam, Practice, Create")
end
test "has link to asset booking page with correct asset id", %{conn: conn, asset: asset} do
test "has link to space booking page with correct space slug", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, "/music")
assert has_element?(view, "a[href='/book/asset/#{asset.id}']", "Book This Room")
assert has_element?(view, "a[href='/book/space/#{space.slug}']")
end
end
end

View file

@ -6,7 +6,7 @@ defmodule SpazioSolazzoWeb.PageLiveTest do
setup do
for {name, slug} <- [{"Coworking", "coworking"}, {"Meeting", "meeting"}, {"Music", "music"}] do
BookingSystem.create_space!(name, slug, "desc")
BookingSystem.create_space!(name, slug, "desc", 10)
end
:ok

View file

@ -123,14 +123,15 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
conn: conn,
user: user
} do
{_space, asset, time_slot} = create_booking_fixtures()
{space, _time_slot} = create_booking_fixtures()
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
Date.utc_today(),
~T[09:00:00],
~T[11:00:00],
"Test User",
"test@example.com",
"+1234567890",
@ -158,14 +159,15 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
conn: conn,
user: user
} do
{_space, asset, time_slot} = create_booking_fixtures()
{space, _time_slot} = create_booking_fixtures()
{:ok, booking} =
BookingSystem.create_booking(
time_slot.id,
asset.id,
space.id,
user.id,
Date.utc_today(),
~T[09:00:00],
~T[11:00:00],
"Test User",
"test@example.com",
"+1234567890",
@ -199,11 +201,10 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
BookingSystem.create_space(
"Test Space #{unique_id}",
"test-space-#{unique_id}",
"Test description"
"Test description",
10
)
{:ok, asset} = BookingSystem.create_asset("Test Asset", space.id)
{:ok, time_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
@ -212,6 +213,6 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
space.id
)
{space, asset, time_slot}
{space, time_slot}
end
end