feat: implement first draft of booking management tool & walk-in

This commit is contained in:
JasterV 2026-02-02 00:04:29 +01:00
parent 22964bb8ad
commit 9136c9ae6d
16 changed files with 3059 additions and 828 deletions

View file

@ -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

View file

@ -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

View file

@ -6,7 +6,8 @@ defmodule SpazioSolazzo.BookingSystem.Space do
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "spaces"
@ -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

View 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

View file

@ -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>

View 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

View file

@ -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>

View file

@ -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

View file

@ -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>

View 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

View 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>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,