mirror of
https://codeberg.org/JasterV/spazio-solazzo.git
synced 2026-04-26 18:20:03 +00:00
feat: implement first draft of booking management tool & walk-in
This commit is contained in:
parent
22964bb8ad
commit
9136c9ae6d
16 changed files with 3059 additions and 828 deletions
|
|
@ -6,9 +6,6 @@ defmodule SpazioSolazzo.BookingSystem do
|
|||
use Ash.Domain,
|
||||
otp_app: :spazio_solazzo
|
||||
|
||||
require Ash.Query
|
||||
alias SpazioSolazzo.BookingSystem.Space
|
||||
|
||||
resources do
|
||||
resource SpazioSolazzo.BookingSystem.Space do
|
||||
define :get_space_by_slug, action: :read, get_by: [:slug]
|
||||
|
|
@ -16,6 +13,10 @@ defmodule SpazioSolazzo.BookingSystem do
|
|||
define :create_space,
|
||||
action: :create,
|
||||
args: [:name, :slug, :description, :public_capacity, :real_capacity]
|
||||
|
||||
define :check_availability,
|
||||
action: :check_availability,
|
||||
args: [:space_id, :date, :start_time, :end_time]
|
||||
end
|
||||
|
||||
resource SpazioSolazzo.BookingSystem.TimeSlotTemplate do
|
||||
|
|
@ -37,6 +38,12 @@ defmodule SpazioSolazzo.BookingSystem do
|
|||
action: :list_booking_requests,
|
||||
args: [:space_id, :email, :date]
|
||||
|
||||
define :count_pending_requests, action: :count_pending_requests
|
||||
|
||||
define :get_slot_booking_counts,
|
||||
action: :get_slot_booking_counts,
|
||||
args: [:space_id, :date, :start_time, :end_time]
|
||||
|
||||
define :create_booking,
|
||||
action: :create,
|
||||
args: [
|
||||
|
|
@ -51,105 +58,22 @@ defmodule SpazioSolazzo.BookingSystem do
|
|||
:customer_comment
|
||||
]
|
||||
|
||||
define :create_walk_in,
|
||||
action: :create_walk_in,
|
||||
args: [
|
||||
:space_id,
|
||||
:start_datetime,
|
||||
:end_datetime,
|
||||
:customer_name,
|
||||
:customer_email,
|
||||
:customer_phone,
|
||||
:customer_comment
|
||||
]
|
||||
|
||||
define :approve_booking, action: :approve, args: []
|
||||
define :reject_booking, action: :reject, args: [:reason]
|
||||
define :cancel_booking, action: :cancel, args: [:reason]
|
||||
define :delete_booking, action: :destroy, args: []
|
||||
end
|
||||
end
|
||||
|
||||
def request_booking(space_id, user_id, date, start_time, end_time, customer_details) do
|
||||
create_booking(
|
||||
space_id,
|
||||
user_id,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
customer_details.name,
|
||||
customer_details.email,
|
||||
customer_details[:phone],
|
||||
customer_details[:comment]
|
||||
)
|
||||
end
|
||||
|
||||
def create_walk_in(space_id, customer_details, start_datetime, end_datetime) do
|
||||
date = DateTime.to_date(start_datetime)
|
||||
start_time = DateTime.to_time(start_datetime)
|
||||
end_time = DateTime.to_time(end_datetime)
|
||||
|
||||
case create_booking(
|
||||
space_id,
|
||||
nil,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
customer_details.name,
|
||||
customer_details.email,
|
||||
customer_details[:phone],
|
||||
customer_details[:comment]
|
||||
) do
|
||||
{:ok, booking} ->
|
||||
approve_booking!(booking)
|
||||
{:ok, booking}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def check_availability(space_id, date, start_time, end_time) do
|
||||
with {:ok, space} <- Ash.get(Space, space_id),
|
||||
{:ok, bookings} <- list_accepted_space_bookings_by_date(space_id, date) do
|
||||
overlapping_bookings =
|
||||
Enum.filter(bookings, fn booking ->
|
||||
times_overlap?(
|
||||
booking.start_time,
|
||||
booking.end_time,
|
||||
start_time,
|
||||
end_time
|
||||
)
|
||||
end)
|
||||
|
||||
current_count = length(overlapping_bookings)
|
||||
|
||||
cond do
|
||||
current_count >= space.real_capacity ->
|
||||
{:ok, :over_real_capacity}
|
||||
|
||||
current_count >= space.public_capacity ->
|
||||
{:ok, :over_public_capacity}
|
||||
|
||||
true ->
|
||||
{:ok, :available}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_slot_booking_counts(space_id, date, start_time, end_time) do
|
||||
with {:ok, all_bookings} <- list_booking_requests(space_id, nil, date) do
|
||||
overlapping_bookings =
|
||||
Enum.filter(all_bookings, fn booking ->
|
||||
times_overlap?(
|
||||
booking.start_time,
|
||||
booking.end_time,
|
||||
start_time,
|
||||
end_time
|
||||
)
|
||||
end)
|
||||
|
||||
pending_count =
|
||||
overlapping_bookings
|
||||
|> Enum.count(&(&1.state == :requested))
|
||||
|
||||
approved_count =
|
||||
overlapping_bookings
|
||||
|> Enum.count(&(&1.state == :accepted))
|
||||
|
||||
{:ok, %{pending: pending_count, approved: approved_count}}
|
||||
end
|
||||
end
|
||||
|
||||
defp times_overlap?(start1, end1, start2, end2) do
|
||||
Time.compare(start1, end2) == :lt and Time.compare(start2, end1) == :lt
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
|
|||
end
|
||||
end
|
||||
|
||||
read :count_pending_requests do
|
||||
filter expr(state == :requested)
|
||||
end
|
||||
|
||||
|
||||
create :create do
|
||||
argument :space_id, :uuid, allow_nil?: false
|
||||
argument :user_id, :uuid, allow_nil?: true
|
||||
|
|
@ -179,6 +184,82 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
|
|||
end)
|
||||
end
|
||||
|
||||
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
|
||||
argument :customer_comment, :string, allow_nil?: true
|
||||
|
||||
change manage_relationship(:space_id, :space, type: :append_and_remove)
|
||||
|
||||
validate fn changeset, _ctx ->
|
||||
start_datetime = Ash.Changeset.get_argument(changeset, :start_datetime)
|
||||
now = DateTime.utc_now()
|
||||
|
||||
if start_datetime && DateTime.compare(start_datetime, now) == :lt do
|
||||
{:error, field: :start_datetime, message: "cannot be in the past"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
validate fn changeset, _ctx ->
|
||||
start_datetime = Ash.Changeset.get_argument(changeset, :start_datetime)
|
||||
end_datetime = Ash.Changeset.get_argument(changeset, :end_datetime)
|
||||
|
||||
if start_datetime && end_datetime &&
|
||||
DateTime.compare(end_datetime, start_datetime) != :gt do
|
||||
{:error, field: :end_datetime, message: "must be after start datetime"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
validate fn changeset, _ctx ->
|
||||
email = Ash.Changeset.get_argument(changeset, :customer_email)
|
||||
|
||||
if email && !String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
|
||||
{:error, field: :customer_email, message: "must be a valid email"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
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(: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)
|
||||
)
|
||||
|> Ash.Changeset.force_change_attribute(
|
||||
:customer_comment,
|
||||
Ash.Changeset.get_argument(changeset, :customer_comment)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
update :approve do
|
||||
accept []
|
||||
require_atomic? false
|
||||
|
|
@ -274,10 +355,54 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
|
|||
description "Delete a booking record"
|
||||
primary? true
|
||||
end
|
||||
|
||||
action :get_slot_booking_counts, :map do
|
||||
argument :space_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
|
||||
|
||||
run fn input, _context ->
|
||||
space_id = input.arguments.space_id
|
||||
date = input.arguments.date
|
||||
start_time = input.arguments.start_time
|
||||
end_time = input.arguments.end_time
|
||||
|
||||
query =
|
||||
__MODULE__
|
||||
|> Ash.Query.filter(date == ^date)
|
||||
|> Ash.Query.filter(state == :requested or state == :accepted)
|
||||
|
||||
query =
|
||||
if space_id do
|
||||
Ash.Query.filter(query, space_id == ^space_id)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, bookings} ->
|
||||
# Filter overlapping bookings
|
||||
overlapping_bookings =
|
||||
Enum.filter(bookings, fn booking ->
|
||||
Time.compare(booking.start_time, end_time) == :lt and
|
||||
Time.compare(start_time, booking.end_time) == :lt
|
||||
end)
|
||||
|
||||
pending_count = Enum.count(overlapping_bookings, &(&1.state == :requested))
|
||||
approved_count = Enum.count(overlapping_bookings, &(&1.state == :accepted))
|
||||
|
||||
{:ok, %{pending: pending_count, approved: approved_count}}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action([:cancel, :approve, :reject]) do
|
||||
policy action([:cancel, :approve, :reject, :get_slot_booking_counts]) do
|
||||
authorize_if always()
|
||||
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"
|
||||
|
|
@ -39,6 +40,60 @@ defmodule SpazioSolazzo.BookingSystem.Space do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
action :check_availability, :atom do
|
||||
argument :space_id, :uuid, allow_nil?: false
|
||||
argument :date, :date, allow_nil?: false
|
||||
argument :start_time, :time, allow_nil?: false
|
||||
argument :end_time, :time, allow_nil?: false
|
||||
|
||||
run fn input, _context ->
|
||||
require Ash.Query
|
||||
|
||||
space_id = input.arguments.space_id
|
||||
date_arg = input.arguments.date
|
||||
start_time_arg = input.arguments.start_time
|
||||
end_time_arg = input.arguments.end_time
|
||||
|
||||
# Load the space
|
||||
case Ash.get(__MODULE__, space_id) do
|
||||
{:ok, space} ->
|
||||
# Get accepted bookings for this space on the given date
|
||||
query =
|
||||
SpazioSolazzo.BookingSystem.Booking
|
||||
|> Ash.Query.filter(
|
||||
expr(space_id == ^space_id and date == ^date_arg and state == :accepted)
|
||||
)
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, bookings} ->
|
||||
# Filter overlapping bookings
|
||||
overlapping_bookings =
|
||||
Enum.filter(bookings, fn booking ->
|
||||
Time.compare(booking.start_time, end_time_arg) == :lt and
|
||||
Time.compare(start_time_arg, booking.end_time) == :lt
|
||||
end)
|
||||
|
||||
current_count = length(overlapping_bookings)
|
||||
|
||||
availability =
|
||||
cond do
|
||||
current_count >= space.real_capacity -> :over_real_capacity
|
||||
current_count >= space.public_capacity -> :over_public_capacity
|
||||
true -> :available
|
||||
end
|
||||
|
||||
{:ok, availability}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -54,4 +109,18 @@ defmodule SpazioSolazzo.BookingSystem.Space do
|
|||
identity :unique_name, [:name]
|
||||
identity :unique_slug, [:slug]
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action(:check_availability) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action_type(:create) do
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
288
lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex
Normal file
288
lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
defmodule SpazioSolazzoWeb.Admin.AdminCalendarComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for admin calendar with capacity tracking and multi-day selection.
|
||||
"""
|
||||
|
||||
use SpazioSolazzoWeb, :live_component
|
||||
|
||||
alias SpazioSolazzo.BookingSystem
|
||||
|
||||
def mount(socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:current_month, fn -> Date.utc_today() 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)
|
||||
|
||||
# Subscribe to booking events for real-time updates
|
||||
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, compute_calendar_data(socket)}
|
||||
end
|
||||
|
||||
def handle_event("prev_month", _, socket) do
|
||||
new_month = Date.add(socket.assigns.current_month, -30)
|
||||
first_of_month = Date.beginning_of_month(new_month)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(current_month: first_of_month)
|
||||
|> compute_calendar_data()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("next_month", _, socket) do
|
||||
new_month = Date.add(socket.assigns.current_month, 30)
|
||||
first_of_month = Date.beginning_of_month(new_month)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(current_month: first_of_month)
|
||||
|> compute_calendar_data()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle_multi_day", %{"value" => value}, socket) do
|
||||
multi_day = value == "on"
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(
|
||||
multi_day_mode: multi_day,
|
||||
start_date: nil,
|
||||
end_date: nil,
|
||||
selected_date: nil
|
||||
)
|
||||
|
||||
# Notify parent of the change
|
||||
send(self(), {:multi_day_mode_changed, multi_day})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_date", %{"date" => date_string}, socket) do
|
||||
case Date.from_iso8601(date_string) do
|
||||
{:ok, date} ->
|
||||
# Check if date is in the past
|
||||
if Date.compare(date, Date.utc_today()) == :lt do
|
||||
{:noreply, socket}
|
||||
else
|
||||
# Check capacity
|
||||
capacity_status = Map.get(socket.assigns.day_capacities, date, :available)
|
||||
|
||||
if capacity_status == :over_real_capacity do
|
||||
{:noreply, socket}
|
||||
else
|
||||
socket =
|
||||
if socket.assigns.multi_day_mode do
|
||||
handle_multi_day_selection(socket, date)
|
||||
else
|
||||
handle_single_day_selection(socket, date)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_single_day_selection(socket, date) do
|
||||
socket = assign(socket, selected_date: date, start_date: nil, end_date: nil)
|
||||
|
||||
# Notify parent
|
||||
send(self(), {:date_selected, date, date})
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp handle_multi_day_selection(socket, date) do
|
||||
cond do
|
||||
socket.assigns.start_date == nil ->
|
||||
# First click - set start date
|
||||
assign(socket, start_date: date, end_date: nil, selected_date: nil)
|
||||
|
||||
socket.assigns.end_date == nil ->
|
||||
# Second click - set end date
|
||||
start_date = socket.assigns.start_date
|
||||
|
||||
{actual_start, actual_end} =
|
||||
if Date.compare(date, start_date) == :lt do
|
||||
{date, start_date}
|
||||
else
|
||||
{start_date, date}
|
||||
end
|
||||
|
||||
socket = assign(socket, start_date: actual_start, end_date: actual_end)
|
||||
|
||||
# Notify parent
|
||||
send(self(), {:date_selected, actual_start, actual_end})
|
||||
|
||||
socket
|
||||
|
||||
true ->
|
||||
# Reset and start new selection
|
||||
assign(socket, start_date: date, end_date: nil, selected_date: nil)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_calendar_data(socket) do
|
||||
space_id = socket.assigns.space_id
|
||||
current_month = socket.assigns.current_month
|
||||
|
||||
# Get all days in the current month
|
||||
first_day = Date.beginning_of_month(current_month)
|
||||
last_day = Date.end_of_month(current_month)
|
||||
|
||||
# Calculate capacity for each day
|
||||
day_capacities =
|
||||
first_day
|
||||
|> Date.range(last_day)
|
||||
|> Enum.map(fn date ->
|
||||
capacity = get_day_capacity(space_id, date)
|
||||
{date, capacity}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
# Build calendar grid
|
||||
calendar_weeks = build_calendar_grid(first_day, last_day)
|
||||
|
||||
assign(socket,
|
||||
day_capacities: day_capacities,
|
||||
calendar_weeks: calendar_weeks,
|
||||
month_name: Calendar.strftime(current_month, "%B %Y")
|
||||
)
|
||||
end
|
||||
|
||||
defp get_day_capacity(space_id, date) do
|
||||
# Get the space to check capacities
|
||||
case Ash.get(SpazioSolazzo.BookingSystem.Space, space_id) do
|
||||
{:ok, space} ->
|
||||
# Get all bookings for this day
|
||||
case BookingSystem.list_accepted_space_bookings_by_date(space_id, date) do
|
||||
{:ok, bookings} ->
|
||||
# Count unique booking slots (simplified - counts all bookings)
|
||||
booking_count = length(bookings)
|
||||
|
||||
cond do
|
||||
booking_count >= space.real_capacity -> :over_real_capacity
|
||||
booking_count >= space.public_capacity -> :over_public_capacity
|
||||
true -> :available
|
||||
end
|
||||
|
||||
_ ->
|
||||
:available
|
||||
end
|
||||
|
||||
_ ->
|
||||
:available
|
||||
end
|
||||
end
|
||||
|
||||
defp build_calendar_grid(first_day, last_day) do
|
||||
# Get the day of week for the first day (1 = Monday, 7 = Sunday)
|
||||
start_day_of_week = Date.day_of_week(first_day)
|
||||
|
||||
# Calculate how many empty cells we need at the start
|
||||
# We want Sunday to be 0, Monday to be 1, etc.
|
||||
padding_days =
|
||||
case start_day_of_week do
|
||||
7 -> 0
|
||||
n -> n
|
||||
end
|
||||
|
||||
# Create the padding
|
||||
padding = List.duplicate(nil, padding_days)
|
||||
|
||||
# Get all days in the month
|
||||
days =
|
||||
first_day
|
||||
|> Date.range(last_day)
|
||||
|> Enum.to_list()
|
||||
|
||||
# Combine and chunk into weeks
|
||||
(padding ++ days)
|
||||
|> Enum.chunk_every(7, 7, List.duplicate(nil, 7))
|
||||
end
|
||||
|
||||
defp day_in_range?(_date, nil, nil, nil), do: false
|
||||
defp day_in_range?(date, selected, nil, nil) when not is_nil(selected), do: Date.compare(date, selected) == :eq
|
||||
defp day_in_range?(date, nil, start_date, nil) when not is_nil(start_date), do: Date.compare(date, start_date) == :eq
|
||||
|
||||
defp day_in_range?(date, nil, 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
|
||||
|
||||
defp day_in_range?(_, _, _, _), do: false
|
||||
|
||||
defp is_start_date?(_date, nil, _), do: false
|
||||
defp is_start_date?(date, start_date, _), do: Date.compare(date, start_date) == :eq
|
||||
|
||||
defp is_end_date?(_date, _, nil), do: false
|
||||
defp is_end_date?(date, _, end_date), do: Date.compare(date, end_date) == :eq
|
||||
|
||||
defp day_classes(date, socket) do
|
||||
capacity = Map.get(socket.assigns.day_capacities, date, :available)
|
||||
is_past = Date.compare(date, Date.utc_today()) == :lt
|
||||
in_range = day_in_range?(date, socket.assigns.selected_date, socket.assigns.start_date, socket.assigns.end_date)
|
||||
is_start = is_start_date?(date, socket.assigns.start_date, socket.assigns.end_date)
|
||||
is_end = is_end_date?(date, socket.assigns.start_date, socket.assigns.end_date)
|
||||
|
||||
base = "aspect-square flex flex-col items-center justify-center transition-all"
|
||||
|
||||
cond do
|
||||
is_past ->
|
||||
[base, "text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50"]
|
||||
|
||||
capacity == :over_real_capacity ->
|
||||
[base, "bg-red-50 dark:bg-red-900/20 text-slate-400 dark:text-slate-500 border border-red-300 dark:border-red-800/30 cursor-not-allowed"]
|
||||
|
||||
in_range && socket.assigns.multi_day_mode && socket.assigns.end_date != nil ->
|
||||
cond do
|
||||
is_start ->
|
||||
[base, "rounded-l-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"]
|
||||
|
||||
is_end ->
|
||||
[base, "rounded-r-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"]
|
||||
|
||||
true ->
|
||||
[base, "bg-primary/20 dark:bg-primary/30 text-slate-900 dark:text-white border-y border-primary/20 dark:border-primary/50"]
|
||||
end
|
||||
|
||||
in_range ->
|
||||
[base, "rounded-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"]
|
||||
|
||||
capacity == :over_public_capacity ->
|
||||
[base, "rounded-lg bg-orange-100 dark:bg-orange-900/20 hover:bg-orange-200 dark:hover:bg-orange-900/40 text-slate-700 dark:text-slate-200 border border-transparent hover:border-orange-500 dark:hover:border-orange-600"]
|
||||
|
||||
true ->
|
||||
[base, "rounded-lg bg-green-100 dark:bg-green-900/20 hover:bg-green-200 dark:hover:bg-green-900/40 text-slate-700 dark:text-slate-200 border border-transparent hover:border-green-500 dark:hover:border-green-600"]
|
||||
end
|
||||
end
|
||||
|
||||
defp capacity_indicator_color(capacity) do
|
||||
case capacity do
|
||||
:available -> "bg-green-500"
|
||||
:over_public_capacity -> "bg-orange-500"
|
||||
:over_real_capacity -> "bg-red-500"
|
||||
_ -> "bg-slate-300"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
<div class="flex flex-col gap-4">
|
||||
<%!-- Multi-day mode toggle --%>
|
||||
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={"multi-day-#{@id}"}
|
||||
phx-change="toggle_multi_day"
|
||||
phx-target={@myself}
|
||||
checked={@multi_day_mode}
|
||||
class="size-4 rounded border-slate-300 dark:border-slate-600 text-primary focus:ring-primary dark:bg-slate-700"
|
||||
/>
|
||||
<label for={"multi-day-#{@id}"} class="text-sm font-semibold text-slate-700 dark:text-slate-300 cursor-pointer select-none">
|
||||
Enable Multi-Day Selection
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<%!-- Legend --%>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
Availability Calendar
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs font-medium">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-slate-600 dark:text-slate-400">Available</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-orange-500"></div>
|
||||
<span class="text-slate-600 dark:text-slate-400">High Demand</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span class="text-slate-600 dark:text-slate-400">Full</span>
|
||||
</div>
|
||||
<%= if @multi_day_mode && @start_date do %>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
<span class="text-slate-600 dark:text-slate-400">Selected</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Calendar --%>
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 rounded-2xl p-4">
|
||||
<%!-- Month navigation --%>
|
||||
<div class="flex items-center justify-between mb-4 px-2">
|
||||
<button
|
||||
phx-click="prev_month"
|
||||
phx-target={@myself}
|
||||
class="p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg text-slate-600 dark:text-slate-400 transition-colors"
|
||||
>
|
||||
<.icon name="hero-chevron-left" class="w-5 h-5" />
|
||||
</button>
|
||||
<h4 class="font-bold text-slate-900 dark:text-white">
|
||||
{@month_name}
|
||||
</h4>
|
||||
<button
|
||||
phx-click="next_month"
|
||||
phx-target={@myself}
|
||||
class="p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg text-slate-600 dark:text-slate-400 transition-colors"
|
||||
>
|
||||
<.icon name="hero-chevron-right" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Day of week headers --%>
|
||||
<div class="grid grid-cols-7 gap-1 mb-2 text-center">
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Su</span>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Mo</span>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Tu</span>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">We</span>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Th</span>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Fr</span>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Sa</span>
|
||||
</div>
|
||||
|
||||
<%!-- Calendar grid --%>
|
||||
<div class="grid grid-cols-7 gap-1 md:gap-2">
|
||||
<%= for week <- @calendar_weeks do %>
|
||||
<%= for day <- week do %>
|
||||
<%= if day == nil do %>
|
||||
<div class="aspect-square"></div>
|
||||
<% else %>
|
||||
<% capacity = Map.get(@day_capacities, day, :available) %>
|
||||
<% is_past = Date.compare(day, Date.utc_today()) == :lt %>
|
||||
<% is_disabled = is_past || capacity == :over_real_capacity %>
|
||||
<% is_start = is_start_date?(day, @start_date, @end_date) %>
|
||||
<% is_end = is_end_date?(day, @start_date, @end_date) %>
|
||||
|
||||
<button
|
||||
phx-click={if !is_disabled, do: "select_date", else: nil}
|
||||
phx-value-date={Date.to_iso8601(day)}
|
||||
phx-target={@myself}
|
||||
class={day_classes(day, assigns)}
|
||||
disabled={is_disabled}
|
||||
title={
|
||||
cond do
|
||||
is_past -> "Past date"
|
||||
capacity == :over_real_capacity -> "Fully Booked"
|
||||
true -> "Select date"
|
||||
end
|
||||
}
|
||||
>
|
||||
<span class={[
|
||||
"text-sm",
|
||||
cond do
|
||||
is_start || is_end -> "font-bold"
|
||||
true -> "font-medium"
|
||||
end
|
||||
]}>
|
||||
{day.day}
|
||||
</span>
|
||||
|
||||
<%= cond do %>
|
||||
<% is_start && @multi_day_mode && @end_date != nil -> %>
|
||||
<span class="text-[10px] uppercase font-bold tracking-tighter mt-0.5">
|
||||
Start
|
||||
</span>
|
||||
<% is_end && @multi_day_mode && @end_date != nil -> %>
|
||||
<span class="text-[10px] uppercase font-bold tracking-tighter mt-0.5">
|
||||
End
|
||||
</span>
|
||||
<% !is_past && !is_start && !is_end -> %>
|
||||
<div class={[
|
||||
"h-1 w-1 rounded-full mt-1",
|
||||
capacity_indicator_color(capacity)
|
||||
]}>
|
||||
</div>
|
||||
<% true -> %>
|
||||
<div class="h-1 mt-1"></div>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Warning for capacity issues in multi-day range --%>
|
||||
<%= if @multi_day_mode && @start_date && @end_date do %>
|
||||
<% has_full_days =
|
||||
@start_date
|
||||
|> Date.range(@end_date)
|
||||
|> Enum.any?(fn date ->
|
||||
Map.get(@day_capacities, date, :available) == :over_real_capacity
|
||||
end) %>
|
||||
|
||||
<%= if has_full_days do %>
|
||||
<div class="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 p-4 flex gap-3 items-start animate-pulse">
|
||||
<div class="shrink-0 text-red-600 dark:text-red-400 mt-0.5">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-red-900 dark:text-red-200">
|
||||
Attention
|
||||
</h4>
|
||||
<p class="text-xs font-medium text-red-700 dark:text-red-300 mt-1">
|
||||
Some days in your selected range have reached maximum capacity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
213
lib/spazio_solazzo_web/live/admin/booking_management_live.ex
Normal file
213
lib/spazio_solazzo_web/live/admin/booking_management_live.ex
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
|
||||
@moduledoc """
|
||||
Admin booking management tool for reviewing and managing all booking requests.
|
||||
"""
|
||||
|
||||
use SpazioSolazzoWeb, :live_view
|
||||
|
||||
alias SpazioSolazzo.BookingSystem
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, spaces} = Ash.read(SpazioSolazzo.BookingSystem.Space)
|
||||
{:ok, bookings} = BookingSystem.list_booking_requests(nil, nil, nil, load: [:space, :user])
|
||||
|
||||
# Separate pending and other bookings
|
||||
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
|
||||
|
||||
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,
|
||||
pending_bookings: pending,
|
||||
past_bookings: past,
|
||||
filter_space_id: nil,
|
||||
filter_email: "",
|
||||
filter_date: nil,
|
||||
show_reject_modal: false,
|
||||
rejecting_booking_id: nil,
|
||||
rejection_reason: "",
|
||||
expanded_booking_ids: MapSet.new()
|
||||
)}
|
||||
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("filter_bookings", params, socket) do
|
||||
space_id = if params["space_id"] == "", do: nil, else: params["space_id"]
|
||||
email = if params["email"] == "", do: nil, else: params["email"]
|
||||
|
||||
date =
|
||||
if params["date"] == "",
|
||||
do: nil,
|
||||
else: Date.from_iso8601!(params["date"])
|
||||
|
||||
{:ok, bookings} =
|
||||
BookingSystem.list_booking_requests(space_id, email, date, load: [:space, :user])
|
||||
|
||||
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
pending_bookings: pending,
|
||||
past_bookings: past,
|
||||
filter_space_id: space_id,
|
||||
filter_email: email || "",
|
||||
filter_date: date
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("clear_filters", _, socket) do
|
||||
{:ok, bookings} = BookingSystem.list_booking_requests(nil, nil, nil, load: [:space, :user])
|
||||
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
pending_bookings: pending,
|
||||
past_bookings: past,
|
||||
filter_space_id: nil,
|
||||
filter_email: "",
|
||||
filter_date: nil
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("approve_booking", %{"booking_id" => booking_id}, socket) do
|
||||
case Ash.get(SpazioSolazzo.BookingSystem.Booking, booking_id) do
|
||||
{:ok, booking} ->
|
||||
case BookingSystem.approve_booking(booking) do
|
||||
{:ok, _approved} ->
|
||||
refresh_bookings(socket)
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to approve booking")}
|
||||
end
|
||||
|
||||
{: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,
|
||||
rejection_reason: ""
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("update_rejection_reason", %{"reason" => reason}, socket) do
|
||||
{:noreply, assign(socket, rejection_reason: reason)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_reject", _, socket) do
|
||||
if String.trim(socket.assigns.rejection_reason) == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please provide a rejection reason")}
|
||||
else
|
||||
case Ash.get(SpazioSolazzo.BookingSystem.Booking, socket.assigns.rejecting_booking_id) do
|
||||
{:ok, booking} ->
|
||||
case BookingSystem.reject_booking(booking, socket.assigns.rejection_reason) do
|
||||
{:ok, _rejected} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(
|
||||
show_reject_modal: false,
|
||||
rejecting_booking_id: nil,
|
||||
rejection_reason: ""
|
||||
)
|
||||
|> put_flash(:info, "Booking rejected")
|
||||
|
||||
refresh_bookings(socket)
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to reject booking")}
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Booking not found")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
%{topic: "booking:" <> _event},
|
||||
socket
|
||||
) do
|
||||
refresh_bookings(socket)
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp refresh_bookings(socket) do
|
||||
{:ok, bookings} =
|
||||
BookingSystem.list_booking_requests(
|
||||
socket.assigns.filter_space_id,
|
||||
socket.assigns.filter_email,
|
||||
socket.assigns.filter_date,
|
||||
load: [:space, :user]
|
||||
)
|
||||
|
||||
{pending, past} = Enum.split_with(bookings, &(&1.state == :requested))
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
pending_bookings: pending,
|
||||
past_bookings: past
|
||||
)}
|
||||
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,481 @@
|
|||
<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">
|
||||
<%!-- Header with back button --%>
|
||||
<div class="flex items-center gap-4">
|
||||
<.link
|
||||
navigate="/admin/dashboard"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-slate-500 hover:text-primary dark:text-slate-400 dark:hover:text-primary transition-colors"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-5 h-5" /> Back to Dashboard
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- 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">{length(@pending_bookings)}</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"
|
||||
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_id"
|
||||
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.id} selected={@filter_space_id == space.id}>
|
||||
{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"
|
||||
>
|
||||
<span>Clear Filters</span>
|
||||
<.icon name="hero-x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<%!-- Pending Bookings Table --%>
|
||||
<%= if @pending_bookings != [] do %>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-4">Pending Requests</h2>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider w-[4%]">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Space
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Customer
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider min-w-[240px]">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<%= for booking <- @pending_bookings do %>
|
||||
<% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %>
|
||||
<tr class={["group", if(is_expanded, do: "expanded", else: "")]}>
|
||||
<td class="px-3 py-4 whitespace-nowrap align-top">
|
||||
<button
|
||||
phx-click="toggle_expand"
|
||||
phx-value-booking_id={booking.id}
|
||||
class="flex items-center justify-center size-7 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<.icon
|
||||
name="hero-chevron-down"
|
||||
class={[
|
||||
"w-4 h-4 transition-transform",
|
||||
if(is_expanded, do: "rotate-180", else: "")
|
||||
]}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="size-8 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
|
||||
<.icon name="hero-building-office" class="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900 dark:text-white">
|
||||
{booking.space.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<p class="text-sm text-slate-900 dark:text-slate-200">
|
||||
{Calendar.strftime(booking.date, "%b %d, %Y")}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<p class="text-sm text-slate-900 dark:text-slate-200">
|
||||
{Calendar.strftime(booking.start_time, "%H:%M")} - {Calendar.strftime(
|
||||
booking.end_time,
|
||||
"%H:%M"
|
||||
)}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900 dark:text-white">
|
||||
{booking.customer_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400">
|
||||
{booking.customer_email}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class={[
|
||||
status_badge_classes(booking.state),
|
||||
"text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1 w-fit"
|
||||
]}>
|
||||
<.icon name={status_icon(booking.state)} class="w-3.5 h-3.5" />
|
||||
{status_label(booking.state)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex justify-center gap-3">
|
||||
<button
|
||||
phx-click="show_reject_modal"
|
||||
phx-value-booking_id={booking.id}
|
||||
class="flex items-center justify-center px-4 py-2 rounded-lg border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 bg-white dark:bg-transparent hover:bg-red-50 dark:hover:bg-red-900/20 font-bold text-sm transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
phx-click="approve_booking"
|
||||
phx-value-booking_id={booking.id}
|
||||
class="flex items-center justify-center px-4 py-2 rounded-lg bg-primary hover:bg-primary-hover text-white font-bold text-sm transition-colors shadow-sm"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<%= if is_expanded do %>
|
||||
<tr class="bg-slate-50 dark:bg-slate-900/50">
|
||||
<td class="px-3 py-2"></td>
|
||||
<td
|
||||
class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400"
|
||||
colspan="6"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>
|
||||
<strong class="font-semibold text-slate-900 dark:text-white">
|
||||
Phone:
|
||||
</strong>
|
||||
<%= if booking.customer_phone do %>
|
||||
{booking.customer_phone}
|
||||
<% else %>
|
||||
<span class="italic text-slate-400">Not provided</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p>
|
||||
<strong class="font-semibold text-slate-900 dark:text-white">
|
||||
Note:
|
||||
</strong>
|
||||
<%= if booking.customer_comment do %>
|
||||
{booking.customer_comment}
|
||||
<% else %>
|
||||
<span class="italic text-slate-400">Not provided</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Past Bookings Table --%>
|
||||
<%= if @past_bookings != [] do %>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-4">Booking History</h2>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider w-[4%]">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Space
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Customer
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<%= for booking <- @past_bookings do %>
|
||||
<% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %>
|
||||
<tr class={["group", if(is_expanded, do: "expanded", else: "")]}>
|
||||
<td class="px-3 py-4 whitespace-nowrap align-top">
|
||||
<button
|
||||
phx-click="toggle_expand"
|
||||
phx-value-booking_id={booking.id}
|
||||
class="flex items-center justify-center size-7 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<.icon
|
||||
name="hero-chevron-down"
|
||||
class={[
|
||||
"w-4 h-4 transition-transform",
|
||||
if(is_expanded, do: "rotate-180", else: "")
|
||||
]}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="size-8 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
|
||||
<.icon name="hero-building-office" class="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900 dark:text-white">
|
||||
{booking.space.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<p class="text-sm text-slate-900 dark:text-slate-200">
|
||||
{Calendar.strftime(booking.date, "%b %d, %Y")}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<p class="text-sm text-slate-900 dark:text-slate-200">
|
||||
{Calendar.strftime(booking.start_time, "%H:%M")} - {Calendar.strftime(
|
||||
booking.end_time,
|
||||
"%H:%M"
|
||||
)}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900 dark:text-white">
|
||||
{booking.customer_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400">
|
||||
{booking.customer_email}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class={[
|
||||
status_badge_classes(booking.state),
|
||||
"text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1 w-fit"
|
||||
]}>
|
||||
<.icon name={status_icon(booking.state)} class="w-3.5 h-3.5" />
|
||||
{status_label(booking.state)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<%= if is_expanded do %>
|
||||
<tr class="bg-slate-50 dark:bg-slate-900/50">
|
||||
<td class="px-3 py-2"></td>
|
||||
<td
|
||||
class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400"
|
||||
colspan="5"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>
|
||||
<strong class="font-semibold text-slate-900 dark:text-white">
|
||||
Phone:
|
||||
</strong>
|
||||
<%= if booking.customer_phone do %>
|
||||
{booking.customer_phone}
|
||||
<% else %>
|
||||
<span class="italic text-slate-400">Not provided</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p>
|
||||
<strong class="font-semibold text-slate-900 dark:text-white">
|
||||
Note:
|
||||
</strong>
|
||||
<%= if booking.customer_comment do %>
|
||||
{booking.customer_comment}
|
||||
<% else %>
|
||||
<span class="italic text-slate-400">Not provided</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<%= if booking.state == :rejected do %>
|
||||
<p>
|
||||
<strong class="font-semibold text-slate-900 dark:text-white">
|
||||
Rejection Reason:
|
||||
</strong>
|
||||
<%= if booking.rejection_reason do %>
|
||||
{booking.rejection_reason}
|
||||
<% else %>
|
||||
<span class="italic text-slate-400">Not provided</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if booking.state == :cancelled do %>
|
||||
<p>
|
||||
<strong class="font-semibold text-slate-900 dark:text-white">
|
||||
Cancellation Reason:
|
||||
</strong>
|
||||
<%= if booking.cancellation_reason do %>
|
||||
{booking.cancellation_reason}
|
||||
<% else %>
|
||||
<span class="italic text-slate-400">Not provided</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @pending_bookings == [] && @past_bookings == [] do %>
|
||||
<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 %>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<%!-- Reject Modal --%>
|
||||
<%= if @show_reject_modal do %>
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
phx-click="hide_reject_modal"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-8"
|
||||
phx-click="stop_propagation"
|
||||
>
|
||||
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-4">Reject Booking</h3>
|
||||
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
Please provide a reason for rejecting this booking request. The customer will receive this reason in their email.
|
||||
</p>
|
||||
|
||||
<form phx-submit="confirm_reject">
|
||||
<div class="mb-6">
|
||||
<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-4 py-3 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-3">
|
||||
<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-6 py-3 rounded-xl font-semibold hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<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 Rejection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule SpazioSolazzoWeb.Admin.DashboardLive do
|
||||
@moduledoc """
|
||||
Admin dashboard for managing booking requests and creating walk-in bookings.
|
||||
Admin dashboard home page showing available management tools.
|
||||
"""
|
||||
|
||||
use SpazioSolazzoWeb, :live_view
|
||||
|
|
@ -8,235 +8,13 @@ defmodule SpazioSolazzoWeb.Admin.DashboardLive do
|
|||
alias SpazioSolazzo.BookingSystem
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, spaces} = Ash.read(SpazioSolazzo.BookingSystem.Space)
|
||||
{:ok, requests} = BookingSystem.list_booking_requests(nil, nil, nil, load: [:space, :user])
|
||||
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
|
||||
end
|
||||
# Get pending requests count for the badge
|
||||
{:ok, pending_requests} = BookingSystem.count_pending_requests()
|
||||
pending_count = length(pending_requests)
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
active_tab: :requests,
|
||||
spaces: spaces,
|
||||
requests: requests,
|
||||
filter_space_id: nil,
|
||||
filter_email: nil,
|
||||
filter_date: nil,
|
||||
show_reject_modal: false,
|
||||
rejecting_booking_id: nil,
|
||||
rejection_reason: "",
|
||||
walk_in_form: %{
|
||||
space_id: nil,
|
||||
customer_name: "",
|
||||
customer_email: "",
|
||||
customer_phone: "",
|
||||
customer_comment: "",
|
||||
start_datetime: nil,
|
||||
end_datetime: nil
|
||||
},
|
||||
capacity_warning: nil
|
||||
pending_requests_count: pending_count
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
||||
{:noreply, assign(socket, active_tab: String.to_existing_atom(tab))}
|
||||
end
|
||||
|
||||
def handle_event("filter_requests", params, socket) do
|
||||
space_id = if params["space_id"] == "", do: nil, else: params["space_id"]
|
||||
email = if params["email"] == "", do: nil, else: params["email"]
|
||||
|
||||
date =
|
||||
if params["date"] == "",
|
||||
do: nil,
|
||||
else: Date.from_iso8601!(params["date"])
|
||||
|
||||
{:ok, requests} =
|
||||
BookingSystem.list_booking_requests(space_id, email, date, load: [:space, :user])
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
requests: requests,
|
||||
filter_space_id: space_id,
|
||||
filter_email: email,
|
||||
filter_date: date
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("approve_booking", %{"booking_id" => booking_id}, socket) do
|
||||
case Ash.get(SpazioSolazzo.BookingSystem.Booking, booking_id) do
|
||||
{:ok, booking} ->
|
||||
case BookingSystem.approve_booking(booking) do
|
||||
{:ok, _approved} ->
|
||||
{:ok, requests} =
|
||||
BookingSystem.list_booking_requests(
|
||||
socket.assigns.filter_space_id,
|
||||
socket.assigns.filter_email,
|
||||
socket.assigns.filter_date,
|
||||
load: [:space, :user]
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(requests: requests)
|
||||
|> put_flash(:info, "Booking approved successfully")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to approve booking")}
|
||||
end
|
||||
|
||||
{: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,
|
||||
rejection_reason: ""
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("update_rejection_reason", %{"reason" => reason}, socket) do
|
||||
{:noreply, assign(socket, rejection_reason: reason)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_reject", _, socket) do
|
||||
if String.trim(socket.assigns.rejection_reason) == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please provide a rejection reason")}
|
||||
else
|
||||
case Ash.get(SpazioSolazzo.BookingSystem.Booking, socket.assigns.rejecting_booking_id) do
|
||||
{:ok, booking} ->
|
||||
case BookingSystem.reject_booking(booking, socket.assigns.rejection_reason) do
|
||||
{:ok, _rejected} ->
|
||||
{:ok, requests} =
|
||||
BookingSystem.list_booking_requests(
|
||||
socket.assigns.filter_space_id,
|
||||
socket.assigns.filter_email,
|
||||
socket.assigns.filter_date,
|
||||
load: [:space, :user]
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
requests: requests,
|
||||
show_reject_modal: false,
|
||||
rejecting_booking_id: nil,
|
||||
rejection_reason: ""
|
||||
)
|
||||
|> put_flash(:info, "Booking rejected")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to reject booking")}
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Booking not found")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("update_walk_in_form", params, socket) do
|
||||
form = socket.assigns.walk_in_form
|
||||
|
||||
updated_form =
|
||||
Map.merge(form, %{
|
||||
space_id: params["space_id"],
|
||||
customer_name: params["customer_name"] || form.customer_name,
|
||||
customer_email: params["customer_email"] || form.customer_email,
|
||||
customer_phone: params["customer_phone"] || form.customer_phone,
|
||||
customer_comment: params["customer_comment"] || form.customer_comment,
|
||||
start_datetime: parse_datetime(params["start_datetime"]),
|
||||
end_datetime: parse_datetime(params["end_datetime"])
|
||||
})
|
||||
|
||||
{:noreply, assign(socket, walk_in_form: updated_form)}
|
||||
end
|
||||
|
||||
def handle_event("create_walk_in", _, socket) do
|
||||
form = socket.assigns.walk_in_form
|
||||
|
||||
with true <- form.space_id != nil,
|
||||
true <- form.customer_name != "",
|
||||
true <- form.customer_email != "",
|
||||
true <- form.start_datetime != nil,
|
||||
true <- form.end_datetime != nil do
|
||||
case BookingSystem.create_walk_in(
|
||||
form.space_id,
|
||||
%{
|
||||
name: form.customer_name,
|
||||
email: form.customer_email,
|
||||
phone: form.customer_phone,
|
||||
comment: form.customer_comment
|
||||
},
|
||||
form.start_datetime,
|
||||
form.end_datetime
|
||||
) do
|
||||
{:ok, _booking} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
walk_in_form: %{
|
||||
space_id: nil,
|
||||
customer_name: "",
|
||||
customer_email: "",
|
||||
customer_phone: "",
|
||||
customer_comment: "",
|
||||
start_datetime: nil,
|
||||
end_datetime: nil
|
||||
},
|
||||
capacity_warning: nil
|
||||
)
|
||||
|> put_flash(:info, "Walk-in booking created successfully")}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to create walk-in: #{inspect(error)}")}
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
{:noreply, put_flash(socket, :error, "Please fill in all required fields")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
%{topic: "booking:created", payload: %{data: _data}},
|
||||
socket
|
||||
) do
|
||||
{:ok, requests} =
|
||||
BookingSystem.list_booking_requests(
|
||||
socket.assigns.filter_space_id,
|
||||
socket.assigns.filter_email,
|
||||
socket.assigns.filter_date,
|
||||
load: [:space, :user]
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, requests: requests)}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp parse_datetime(nil), do: nil
|
||||
defp parse_datetime(""), do: nil
|
||||
|
||||
defp parse_datetime(datetime_string) do
|
||||
case DateTime.from_iso8601(datetime_string <> ":00Z") do
|
||||
{:ok, dt, _} -> dt
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,328 +1,103 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-slate-900 mb-2">Admin Dashboard</h1>
|
||||
<p class="text-lg text-slate-600">Manage booking requests and walk-in customers</p>
|
||||
<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. Choose a tool below to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div class="bg-white rounded-t-2xl shadow-lg">
|
||||
<div class="border-b border-slate-200">
|
||||
<nav class="flex space-x-8 px-8 pt-6" aria-label="Tabs">
|
||||
<button
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="requests"
|
||||
class={[
|
||||
"pb-4 px-1 border-b-2 font-semibold text-sm transition-colors",
|
||||
if(@active_tab == :requests,
|
||||
do: "border-orange-500 text-orange-600",
|
||||
else:
|
||||
"border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
Booking Requests
|
||||
<%= if length(@requests) > 0 do %>
|
||||
<span class="ml-2 bg-orange-100 text-orange-800 py-0.5 px-2.5 rounded-full text-xs font-medium">
|
||||
{length(@requests)}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<%!-- Booking Management Tool Card --%>
|
||||
<.link
|
||||
navigate="/admin/bookings"
|
||||
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 hover:border-primary dark:hover:border-primary 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 bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<.icon name="hero-clipboard-document-list" class="w-8 h-8" />
|
||||
</div>
|
||||
<%= if @pending_requests_count > 0 do %>
|
||||
<span class="bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1">
|
||||
<.icon name="hero-clock" class="w-3.5 h-3.5" />
|
||||
{@pending_requests_count} pending
|
||||
</span>
|
||||
<% end %>
|
||||
</button>
|
||||
|
||||
<button
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="walk_in"
|
||||
class={[
|
||||
"pb-4 px-1 border-b-2 font-semibold text-sm transition-colors",
|
||||
if(@active_tab == :walk_in,
|
||||
do: "border-orange-500 text-orange-600",
|
||||
else:
|
||||
"border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
Walk-in / Custom
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<%= if @active_tab == :requests do %>
|
||||
<%!-- Request Manager Tab --%>
|
||||
<div class="space-y-6">
|
||||
<%!-- Filters --%>
|
||||
<form phx-change="filter_requests" class="bg-slate-50 rounded-xl p-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900 mb-4">Filter Requests</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Space</label>
|
||||
<select
|
||||
name="space_id"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All Spaces</option>
|
||||
<%= for space <- @spaces do %>
|
||||
<option value={space.id} selected={@filter_space_id == space.id}>
|
||||
{space.name}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={@filter_email || ""}
|
||||
placeholder="customer@example.com"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date"
|
||||
value={if @filter_date, do: Date.to_iso8601(@filter_date), else: ""}
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<%!-- Requests List --%>
|
||||
<%= if @requests == [] do %>
|
||||
<div class="text-center py-12 bg-slate-50 rounded-xl">
|
||||
<p class="text-slate-500 text-lg">No pending requests</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<%= for request <- @requests do %>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-6 hover:shadow-md transition-shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-900 mb-3">
|
||||
{request.customer_name}
|
||||
</h4>
|
||||
<div class="space-y-2 text-sm text-slate-600">
|
||||
<p>
|
||||
<strong>Email:</strong>
|
||||
<a
|
||||
href={"mailto:#{request.customer_email}"}
|
||||
class="text-orange-600 hover:underline"
|
||||
>
|
||||
{request.customer_email}
|
||||
</a>
|
||||
</p>
|
||||
<%= if request.customer_phone do %>
|
||||
<p>
|
||||
<strong>Phone:</strong>
|
||||
<a
|
||||
href={"tel:#{request.customer_phone}"}
|
||||
class="text-orange-600 hover:underline"
|
||||
>
|
||||
{request.customer_phone}
|
||||
</a>
|
||||
</p>
|
||||
<% end %>
|
||||
<p>
|
||||
<strong>Space:</strong>
|
||||
{request.space.name}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Date:</strong>
|
||||
{Calendar.strftime(request.date, "%A, %B %d, %Y")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Time:</strong>
|
||||
{request.start_time} - {request.end_time}
|
||||
</p>
|
||||
<%= if request.customer_comment do %>
|
||||
<div class="mt-3 p-3 bg-orange-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-slate-700">Comment:</p>
|
||||
<p class="text-sm text-slate-600 italic">
|
||||
{request.customer_comment}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center space-y-3">
|
||||
<button
|
||||
phx-click="approve_booking"
|
||||
phx-value-booking_id={request.id}
|
||||
class="w-full bg-green-500 text-white px-6 py-3 rounded-xl font-semibold hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
phx-click="show_reject_modal"
|
||||
phx-value-booking_id={request.id}
|
||||
class="w-full bg-red-500 text-white px-6 py-3 rounded-xl font-semibold hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Walk-in Tab --%>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-slate-900 mb-6">Create Walk-in Booking</h3>
|
||||
|
||||
<form phx-change="update_walk_in_form" phx-submit="create_walk_in" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">
|
||||
Space <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="space_id"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">Select a space</option>
|
||||
<%= for space <- @spaces do %>
|
||||
<option value={space.id}>{space.name}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white mb-3 group-hover:text-primary dark:group-hover:text-primary transition-colors">
|
||||
Booking Management
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">
|
||||
Customer Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="customer_name"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6 flex-grow">
|
||||
Review and manage pending booking requests. Approve or reject reservations and view booking history.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">
|
||||
Email <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="customer_email"
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="customer_phone"
|
||||
placeholder="+39 123 456 7890"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">
|
||||
Start Date & Time <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="start_datetime"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">
|
||||
End Date & Time <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="end_datetime"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-900 mb-2">Comment</label>
|
||||
<textarea
|
||||
name="customer_comment"
|
||||
rows="3"
|
||||
placeholder="Any additional notes..."
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full md:w-auto bg-orange-500 text-white px-8 py-3 rounded-xl font-semibold hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Create Walk-in Booking
|
||||
</button>
|
||||
</form>
|
||||
<div class="flex items-center text-primary dark:text-primary-hover 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>
|
||||
<% end %>
|
||||
</div>
|
||||
</.link>
|
||||
|
||||
<%!-- Walk-in Booking Tool Card --%>
|
||||
<.link
|
||||
navigate="/admin/walk-in"
|
||||
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 hover:border-sky-500 dark:hover:border-sky-400 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 bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<.icon name="hero-user-plus" class="w-8 h-8" />
|
||||
</div>
|
||||
<span class="bg-sky-100 dark:bg-sky-900/40 text-sky-800 dark:text-sky-200 text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1">
|
||||
<.icon name="hero-building-office-2" class="w-3.5 h-3.5" /> Coworking Only
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white mb-3 group-hover:text-sky-500 dark:group-hover:text-sky-400 transition-colors">
|
||||
Walk-in Booking
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6 flex-grow">
|
||||
Create instant bookings for walk-in customers at the coworking space. View real-time availability calendar.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center text-sky-500 dark:text-sky-400 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>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 p-6 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 text-slate-400 dark:text-slate-500">
|
||||
<.icon name="hero-information-circle" class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white mb-1">
|
||||
Quick Tips
|
||||
</h3>
|
||||
<ul class="text-sm text-slate-600 dark:text-slate-400 space-y-1">
|
||||
<li>• Booking requests require approval before confirmation</li>
|
||||
<li>• Walk-in bookings are instantly approved for coworking space</li>
|
||||
<li>• All bookings are paid upon customer arrival at reception</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Reject Modal --%>
|
||||
<%= if @show_reject_modal do %>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8">
|
||||
<h3 class="text-2xl font-bold text-slate-900 mb-4">Reject Booking</h3>
|
||||
|
||||
<p class="text-slate-600 mb-6">
|
||||
Please provide a reason for rejecting this booking request. The customer will receive this reason in their email.
|
||||
</p>
|
||||
|
||||
<form phx-submit="confirm_reject">
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-semibold text-slate-900 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-4 py-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>{@rejection_reason}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="hide_reject_modal"
|
||||
class="flex-1 bg-slate-200 text-slate-700 px-6 py-3 rounded-xl font-semibold hover:bg-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<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 Rejection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
190
lib/spazio_solazzo_web/live/admin/walk_in_live.ex
Normal file
190
lib/spazio_solazzo_web/live/admin/walk_in_live.ex
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
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
|
||||
# Get coworking space
|
||||
{:ok, spaces} = Ash.read(SpazioSolazzo.BookingSystem.Space)
|
||||
coworking_space = Enum.find(spaces, &(&1.slug == "coworking"))
|
||||
|
||||
if coworking_space == nil do
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Coworking space not found")
|
||||
|> push_navigate(to: "/admin/dashboard")}
|
||||
else
|
||||
{:ok,
|
||||
assign(socket,
|
||||
coworking_space: coworking_space,
|
||||
multi_day_mode: false,
|
||||
start_date: nil,
|
||||
end_date: nil,
|
||||
selected_date: nil,
|
||||
start_time: ~T[09:00:00],
|
||||
end_time: ~T[18:00:00],
|
||||
customer_name: "",
|
||||
customer_email: "",
|
||||
customer_phone: "",
|
||||
customer_comment: "",
|
||||
time_slot_warning: nil
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("update_start_time", %{"value" => time_str}, socket) do
|
||||
case Time.from_iso8601(time_str <> ":00") do
|
||||
{:ok, time} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(start_time: time)
|
||||
|> check_time_slot_capacity()
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
_ ->
|
||||
{: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} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(end_time: time)
|
||||
|> check_time_slot_capacity()
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("update_customer_name", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, customer_name: value)}
|
||||
end
|
||||
|
||||
def handle_event("update_customer_email", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, customer_email: value)}
|
||||
end
|
||||
|
||||
def handle_event("update_customer_phone", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, customer_phone: value)}
|
||||
end
|
||||
|
||||
def handle_event("update_customer_comment", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, customer_comment: value)}
|
||||
end
|
||||
|
||||
def handle_event("create_booking", _, socket) do
|
||||
with true <- socket.assigns.customer_name != "",
|
||||
true <- socket.assigns.customer_email != "",
|
||||
start_date when not is_nil(start_date) <- get_start_date(socket),
|
||||
end_date when not is_nil(end_date) <- get_end_date(socket) do
|
||||
# Create datetime objects
|
||||
start_datetime =
|
||||
DateTime.new!(start_date, socket.assigns.start_time, "Etc/UTC")
|
||||
|
||||
end_datetime =
|
||||
DateTime.new!(end_date, socket.assigns.end_time, "Etc/UTC")
|
||||
|
||||
case BookingSystem.create_walk_in(
|
||||
socket.assigns.coworking_space.id,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
socket.assigns.customer_name,
|
||||
socket.assigns.customer_email,
|
||||
socket.assigns.customer_phone,
|
||||
socket.assigns.customer_comment
|
||||
) do
|
||||
{:ok, _booking} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
customer_name: "",
|
||||
customer_email: "",
|
||||
customer_phone: "",
|
||||
customer_comment: "",
|
||||
start_date: nil,
|
||||
end_date: nil,
|
||||
selected_date: nil,
|
||||
time_slot_warning: nil
|
||||
)
|
||||
|> put_flash(:info, "Walk-in booking created successfully")}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to create walk-in: #{inspect(error)}")}
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
{:noreply, put_flash(socket, :error, "Please fill in all required fields and select a date")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:multi_day_mode_changed, multi_day}, socket) do
|
||||
{:noreply, assign(socket, multi_day_mode: multi_day)}
|
||||
end
|
||||
|
||||
def handle_info({:date_selected, start_date, end_date}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(start_date: start_date, end_date: end_date, selected_date: nil)
|
||||
|> check_time_slot_capacity()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp get_start_date(socket) do
|
||||
socket.assigns.start_date || socket.assigns.selected_date
|
||||
end
|
||||
|
||||
defp get_end_date(socket) do
|
||||
socket.assigns.end_date || socket.assigns.selected_date
|
||||
end
|
||||
|
||||
defp check_time_slot_capacity(socket) do
|
||||
# Only check for single-day bookings
|
||||
if socket.assigns.multi_day_mode || socket.assigns.selected_date == nil do
|
||||
assign(socket, time_slot_warning: nil)
|
||||
else
|
||||
date = socket.assigns.selected_date
|
||||
|
||||
case BookingSystem.check_availability(
|
||||
socket.assigns.coworking_space.id,
|
||||
date,
|
||||
socket.assigns.start_time,
|
||||
socket.assigns.end_time
|
||||
) do
|
||||
{:ok, :over_real_capacity} ->
|
||||
assign(socket, time_slot_warning: "This time slot is currently full.")
|
||||
|
||||
{:ok, :over_public_capacity} ->
|
||||
assign(socket, time_slot_warning: "This time slot has high demand but space is still available.")
|
||||
|
||||
_ ->
|
||||
assign(socket, time_slot_warning: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp days_selected(nil, nil, nil), do: 0
|
||||
defp days_selected(selected, nil, nil) when not is_nil(selected), do: 1
|
||||
defp days_selected(nil, start_date, nil) when not is_nil(start_date), do: 1
|
||||
|
||||
defp days_selected(nil, start_date, end_date)
|
||||
when not is_nil(start_date) and not is_nil(end_date) do
|
||||
Date.diff(end_date, start_date) + 1
|
||||
end
|
||||
|
||||
defp days_selected(_, _, _), do: 0
|
||||
end
|
||||
285
lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex
Normal file
285
lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<main class="flex-1">
|
||||
<section class="mx-auto max-w-[1000px] px-6 py-10">
|
||||
<%!-- Header with back button --%>
|
||||
<div class="mb-6">
|
||||
<.link
|
||||
navigate="/admin/dashboard"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-slate-500 hover:text-primary dark:text-slate-400 dark:hover:text-primary transition-colors"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-5 h-5" /> Back to Dashboard
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- Title --%>
|
||||
<div class="mb-10 flex flex-col items-center md:items-start text-center md:text-left">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary dark:text-primary-hover dark:bg-primary/20 text-xs font-bold mb-4 border border-primary/20">
|
||||
<.icon name="hero-building-office-2" class="w-4 h-4" />
|
||||
<span>Coworking Space Only</span>
|
||||
</div>
|
||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white tracking-tight mb-3">
|
||||
New Coworking Walk-in Booking
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-lg">
|
||||
Follow the steps below to create a new guided reservation for the coworking area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<%!-- Step 1: 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={@coworking_space.id}
|
||||
/>
|
||||
</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 || @selected_date do %>
|
||||
{Calendar.strftime(@start_date || @selected_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 || @selected_date do %>
|
||||
{Calendar.strftime(@end_date || @selected_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(@selected_date, @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 && @start_date && @end_date 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>
|
||||
|
||||
<%= if @time_slot_warning do %>
|
||||
<div class="mt-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 flex items-start gap-3">
|
||||
<span class="text-red-600 dark:text-red-400 shrink-0">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-red-900 dark:text-red-100 leading-tight">
|
||||
{@time_slot_warning}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<%!-- Step 2: 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>
|
||||
|
||||
<div 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
|
||||
value={@customer_name}
|
||||
phx-change="update_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"
|
||||
/>
|
||||
</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
|
||||
value={@customer_email}
|
||||
phx-change="update_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"
|
||||
/>
|
||||
</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
|
||||
value={@customer_phone}
|
||||
phx-change="update_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>
|
||||
|
||||
<div class="relative">
|
||||
<textarea
|
||||
value={@customer_comment}
|
||||
phx-change="update_customer_comment"
|
||||
rows="3"
|
||||
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 px-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
|
||||
placeholder="Additional notes or comments (Optional)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<%!-- Submit button --%>
|
||||
<div class="sticky bottom-6 z-20">
|
||||
<div class="bg-slate-900 dark:bg-primary rounded-2xl p-4 md:p-6 shadow-2xl border border-slate-800 dark:border-slate-600 flex flex-col sm:flex-row justify-between items-center gap-4 backdrop-blur-xl bg-opacity-95 dark:bg-opacity-95">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-yellow-500/20 p-2 rounded-full hidden sm:block">
|
||||
<.icon name="hero-currency-dollar" class="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white font-bold">Ready to book?</p>
|
||||
<p class="text-xs text-slate-300 dark:text-slate-200">
|
||||
Payment collected upon arrival.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
phx-click="create_booking"
|
||||
class="w-full sm:w-auto flex items-center justify-center gap-2 overflow-hidden rounded-xl h-11 px-8 bg-yellow-500 hover:bg-yellow-400 transition-colors text-slate-900 text-sm font-bold shadow-lg shadow-yellow-500/20"
|
||||
>
|
||||
<span>Confirm Booking</span>
|
||||
<.icon name="hero-arrow-right" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.app>
|
||||
|
|
@ -69,18 +69,16 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
|
|||
current_user = socket.assigns.current_user
|
||||
|
||||
result =
|
||||
BookingSystem.request_booking(
|
||||
BookingSystem.create_booking(
|
||||
socket.assigns.space.id,
|
||||
current_user && current_user.id,
|
||||
socket.assigns.selected_date,
|
||||
socket.assigns.selected_time_slot.start_time,
|
||||
socket.assigns.selected_time_slot.end_time,
|
||||
%{
|
||||
name: booking_data.customer_name,
|
||||
email: (current_user && current_user.email) || booking_data.customer_email,
|
||||
phone: booking_data.customer_phone,
|
||||
comment: booking_data.customer_comment
|
||||
}
|
||||
booking_data.customer_name,
|
||||
(current_user && current_user.email) || booking_data.customer_email,
|
||||
booking_data.customer_phone,
|
||||
booking_data.customer_comment
|
||||
)
|
||||
|
||||
case result do
|
||||
|
|
|
|||
|
|
@ -61,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
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -116,4 +116,312 @@ defmodule SpazioSolazzo.BookingSystem.SpaceTest do
|
|||
assert space1.id != space2.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_availability/4" do
|
||||
setup do
|
||||
{:ok, space} =
|
||||
BookingSystem.create_space(
|
||||
"Availability Test Space",
|
||||
"availability-test",
|
||||
"Test description",
|
||||
2,
|
||||
3
|
||||
)
|
||||
|
||||
%{space: space}
|
||||
end
|
||||
|
||||
test "returns :available when no bookings exist", %{space: space} do
|
||||
date = Date.utc_today()
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "returns :available when under public capacity", %{space: space} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "returns :over_public_capacity when at public capacity but under real capacity", %{
|
||||
space: space
|
||||
} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer 2",
|
||||
"customer2@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
assert {:ok, :over_public_capacity} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "returns :over_real_capacity when at real capacity", %{space: space} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
for i <- 1..3 do
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer #{i}",
|
||||
"customer#{i}@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
end
|
||||
|
||||
assert {:ok, :over_real_capacity} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "only counts overlapping bookings", %{space: space} do
|
||||
date = Date.utc_today()
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, ~T[08:00:00], "Etc/UTC"),
|
||||
DateTime.new!(date, ~T[09:00:00], "Etc/UTC"),
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, ~T[10:00:00], "Etc/UTC"),
|
||||
DateTime.new!(date, ~T[11:00:00], "Etc/UTC"),
|
||||
"Customer 2",
|
||||
"customer2@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "counts partial overlaps", %{space: space} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, ~T[08:30:00], "Etc/UTC"),
|
||||
DateTime.new!(date, ~T[09:30:00], "Etc/UTC"),
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, ~T[09:30:00], "Etc/UTC"),
|
||||
DateTime.new!(date, ~T[10:30:00], "Etc/UTC"),
|
||||
"Customer 2",
|
||||
"customer2@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
assert {:ok, :over_public_capacity} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "does not count pending bookings", %{space: space} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
BookingSystem.create_booking(
|
||||
space.id,
|
||||
nil,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
BookingSystem.create_booking(
|
||||
space.id,
|
||||
nil,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
"Customer 2",
|
||||
"customer2@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "does not count cancelled bookings", %{space: space} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
{:ok, booking1} =
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer 2",
|
||||
"customer2@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
BookingSystem.cancel_booking(booking1, "User requested cancellation")
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "does not count rejected bookings", %{space: space} do
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
{:ok, booking1} =
|
||||
BookingSystem.create_booking(
|
||||
space.id,
|
||||
nil,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
"Customer 1",
|
||||
"customer1@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
BookingSystem.create_booking(
|
||||
space.id,
|
||||
nil,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
"Customer 2",
|
||||
"customer2@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
|
||||
BookingSystem.reject_booking(booking1, "Space not available")
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
|
||||
test "filters by date correctly", %{space: space} do
|
||||
date1 = Date.utc_today()
|
||||
date2 = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
for i <- 1..3 do
|
||||
BookingSystem.create_walk_in(
|
||||
space.id,
|
||||
DateTime.new!(date1, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date1, end_time, "Etc/UTC"),
|
||||
"Customer #{i}",
|
||||
"customer#{i}@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
end
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date2, start_time, end_time)
|
||||
end
|
||||
|
||||
test "filters by space correctly", %{space: space} do
|
||||
{:ok, other_space} =
|
||||
BookingSystem.create_space(
|
||||
"Other Space",
|
||||
"other-space",
|
||||
"Another test space",
|
||||
2,
|
||||
3
|
||||
)
|
||||
|
||||
date = Date.add(Date.utc_today(), 1)
|
||||
start_time = ~T[09:00:00]
|
||||
end_time = ~T[10:00:00]
|
||||
|
||||
for i <- 1..3 do
|
||||
BookingSystem.create_walk_in(
|
||||
other_space.id,
|
||||
DateTime.new!(date, start_time, "Etc/UTC"),
|
||||
DateTime.new!(date, end_time, "Etc/UTC"),
|
||||
"Customer #{i}",
|
||||
"customer#{i}@example.com",
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
end
|
||||
|
||||
assert {:ok, :available} =
|
||||
BookingSystem.check_availability(space.id, date, start_time, end_time)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,21 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
|
||||
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(
|
||||
|
|
@ -212,7 +227,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
} do
|
||||
for i <- 1..2 do
|
||||
{:ok, booking} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -232,7 +247,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
test "hides slots over real capacity", %{conn: conn, space: space, today: today} do
|
||||
for i <- 1..3 do
|
||||
{:ok, booking} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -298,7 +313,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
} do
|
||||
for i <- 1..2 do
|
||||
{:ok, booking} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -436,7 +451,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
assert initial_html =~ "Available - Request Booking"
|
||||
|
||||
{:ok, booking} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -451,7 +466,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
assert html =~ "Available - Request Booking"
|
||||
|
||||
{:ok, booking2} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -468,7 +483,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
|
||||
test "updates when booking is cancelled", %{conn: conn, space: space, today: today} do
|
||||
{:ok, booking1} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -480,7 +495,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
{:ok, _} = BookingSystem.approve_booking(booking1.id)
|
||||
|
||||
{:ok, booking2} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
@ -624,7 +639,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|
|||
)
|
||||
|
||||
{:ok, booking} =
|
||||
BookingSystem.request_booking(
|
||||
request_booking(
|
||||
small_space.id,
|
||||
nil,
|
||||
today,
|
||||
|
|
|
|||
Loading…
Reference in a new issue