mirror of
https://codeberg.org/JasterV/spazio-solazzo.git
synced 2026-04-26 18:20:03 +00:00
feat: new booking system + admin dashboard (#12)
feat: implement a new booking system and admin dashboard
This commit is contained in:
parent
bbc2f08215
commit
69f992f8f6
93 changed files with 7067 additions and 2121 deletions
|
|
@ -1,7 +1,6 @@
|
|||
[
|
||||
import_deps: [
|
||||
:oban,
|
||||
:ash_admin,
|
||||
:ash_authentication_phoenix,
|
||||
:ash_authentication,
|
||||
:ash_postgres,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
31
lib/spazio_solazzo/booking_system/validations/email.ex
Normal file
31
lib/spazio_solazzo/booking_system/validations/email.ex
Normal 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
|
||||
35
lib/spazio_solazzo/booking_system/validations/future_date.ex
Normal file
35
lib/spazio_solazzo/booking_system/validations/future_date.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
248
lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex
Normal file
248
lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex
Normal 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
|
||||
251
lib/spazio_solazzo_web/live/admin/booking_management_live.ex
Normal file
251
lib/spazio_solazzo_web/live/admin/booking_management_live.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
180
lib/spazio_solazzo_web/live/admin/walk_in_live.ex
Normal file
180
lib/spazio_solazzo_web/live/admin/walk_in_live.ex
Normal 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
|
||||
245
lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex
Normal file
245
lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
117
lib/spazio_solazzo_web/live/booking/space_booking_live.html.heex
Normal file
117
lib/spazio_solazzo_web/live/booking/space_booking_live.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
mix.exs
1
mix.exs
|
|
@ -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"},
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
29
priv/repo/migrations/20260202155008_add_booking_indexes.exs
Normal file
29
priv/repo/migrations/20260202155008_add_booking_indexes.exs
Normal 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
|
||||
|
|
@ -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]},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "0B279BA9251EA91451BB80DF86AF81ECCA5395011864BE01BE1C369420AD9C18",
|
||||
"hash": "8150DCC90B96652CEC9629DE7283AED0E40B0E9A860BE5B1F25BC7E6BF8C0570",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
399
test/spazio_solazzo/booking_system/booking_pagination_test.exs
Normal file
399
test/spazio_solazzo/booking_system/booking_pagination_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
279
test/spazio_solazzo_web/live/admin/walk_in_live_test.exs
Normal file
279
test/spazio_solazzo_web/live/admin/walk_in_live_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
680
test/spazio_solazzo_web/live/booking_live/space_booking_test.exs
Normal file
680
test/spazio_solazzo_web/live/booking_live/space_booking_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue