refactor: calendars to share logic and be more simple

This commit is contained in:
JasterV 2026-02-07 16:59:24 +01:00
parent 4b79615a76
commit c01bd8e733
10 changed files with 342 additions and 483 deletions

View file

@ -438,6 +438,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
prefix "booking"
publish :create, ["created"]
publish :create_walk_in, ["created"]
publish :approve, ["approved"]
publish :reject, ["rejected"]
publish :cancel, ["cancelled"]

View file

@ -108,4 +108,37 @@ defmodule SpazioSolazzo.CalendarExt do
format_time(end_datetime)
end
end
# There are 7 days displayed in the calendar
@grid_cols 7
# The calendar can show max 6 weeks for one month
@grid_rows 6
@doc """
Build a list containing all the dates to be displayed in a
Calendar grid.
6 weeks * 7 days = 42 cells
"""
def build_calendar_grid(date) do
first_day = Date.beginning_of_month(date)
# Mon=1, Sun=7
start_day_of_week = Date.day_of_week(first_day)
# Calculate days to subtract to get to the previous Monday
# If starts on Mon (1), sub 0. If Sun (7), sub 6.
days_to_sub = start_day_of_week - 1
start_date = Date.add(first_day, -days_to_sub)
# 6 weeks * 7 days = 42 grid cells
Enum.map(0..(@grid_cols * @grid_rows - 1), fn i -> Date.add(start_date, i) end)
end
@doc "Checks if a date is within a start/end range (inclusive)"
def date_in_range?(date, start_date, end_date)
when not is_nil(start_date) and not is_nil(end_date) do
Date.compare(date, start_date) != :lt and Date.compare(date, end_date) != :gt
end
def date_in_range?(_date, _start, _end), do: false
end

View file

@ -1,290 +1,248 @@
defmodule SpazioSolazzoWeb.Admin.AdminCalendarComponent do
@moduledoc """
LiveComponent for admin calendar with capacity tracking and multi-day selection.
Admin calendar for managing bookings, visualizing capacity, and selecting date ranges.
"""
use SpazioSolazzoWeb, :live_component
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.CalendarExt
@doc "Resets the calendar selection state."
def reset(id) do
send_update(__MODULE__, id: id, reset: true)
end
def update(%{reset: true}, socket) do
socket =
socket
|> assign(start_date: nil)
|> assign(end_date: nil)
|> assign(selected_date: nil)
def mount(socket) do
{:ok, socket}
end
def update(assigns, socket) do
first_day =
assigns[:first_day_of_month] ||
socket.assigns[:first_day_of_month] ||
Date.utc_today() |> Date.beginning_of_month()
grid = CalendarExt.build_calendar_grid(first_day)
socket =
socket
|> assign(assigns)
|> assign_new(:current_month, fn -> Date.utc_today() end)
|> assign_new(:booking_counts, fn -> %{} end)
|> assign_new(:multi_day_mode, fn -> false end)
|> assign_new(:start_date, fn -> nil end)
|> assign_new(:end_date, fn -> nil end)
|> assign_new(:selected_date, fn -> nil end)
|> assign(first_day_of_month: first_day)
|> assign(grid: grid)
# 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)}
{:ok, 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)
new_date = Date.shift(socket.assigns.first_day_of_month, month: -1)
grid = CalendarExt.build_calendar_grid(new_date)
socket =
socket
|> assign(current_month: first_of_month)
|> compute_calendar_data()
|> assign(first_day_of_month: new_date)
|> assign(grid: grid)
send(self(), {:change_month, new_date})
{:noreply, socket}
end
def handle_event("next_month", _, socket) do
new_month = Date.add(socket.assigns.current_month, 30)
first_of_month = Date.beginning_of_month(new_month)
new_date = Date.shift(socket.assigns.first_day_of_month, month: 1)
grid = CalendarExt.build_calendar_grid(new_date)
socket =
socket
|> assign(current_month: first_of_month)
|> compute_calendar_data()
|> assign(first_day_of_month: new_date)
|> assign(grid: grid)
send(self(), {:change_month, new_date})
{:noreply, socket}
end
def handle_event("toggle_multi_day", _params, socket) do
# Toggle the current state
multi_day = !socket.assigns.multi_day_mode
def handle_event("toggle_multi_day", _, socket) do
new_mode = !socket.assigns.multi_day_mode
socket =
socket
|> assign(
multi_day_mode: multi_day,
start_date: nil,
end_date: nil,
selected_date: nil
)
send(self(), {:multi_day_mode_toggle, new_mode})
# Notify parent of the change
send(self(), {:multi_day_mode_changed, multi_day})
{:noreply, socket}
{:noreply,
assign(socket,
multi_day_mode: new_mode,
start_date: nil,
end_date: nil,
selected_date: nil
)}
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
socket =
if socket.assigns.multi_day_mode do
handle_multi_day_selection(socket, date)
else
handle_single_day_selection(socket, date)
end
def handle_event("select_date", %{"date" => d}, socket) do
date = Date.from_iso8601!(d)
{:noreply, socket}
end
_ ->
{:noreply, socket}
if socket.assigns.multi_day_mode do
handle_multi_select(socket, date)
else
send(self(), {:date_selected, date, date})
{:noreply, assign(socket, selected_date: date, start_date: nil, end_date: nil)}
end
end
defp handle_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
defp handle_multi_select(
%{assigns: %{start_date: start_date, end_date: end_date}} = 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)
is_nil(start_date) ->
# Start Selection
{:noreply, assign(socket, start_date: date)}
socket.assigns.end_date == nil ->
# Second click - set end date
start_date = socket.assigns.start_date
is_nil(end_date) ->
# End Selection (Order correctly)
{new_start, new_end} =
if Date.compare(date, start_date) == :lt,
do: {date, start_date},
else: {start_date, date}
{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
send(self(), {:date_selected, new_start, new_end})
{:noreply, assign(socket, start_date: new_start, end_date: new_end)}
true ->
# Reset and start new selection
assign(socket, start_date: date, end_date: nil, selected_date: nil)
# Reset
{:noreply, assign(socket, start_date: date, end_date: nil)}
end
end
defp compute_calendar_data(socket) do
space_id = socket.assigns.space_id
current_month = socket.assigns.current_month
def render(assigns) do
~H"""
<div class="flex flex-col gap-4">
<%!-- Toolbar --%>
<div
class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700"
phx-click="toggle_multi_day"
phx-target={@myself}
>
<input
type="checkbox"
checked={@multi_day_mode}
class="checkbox checkbox-primary checkbox-sm pointer-events-none"
/>
<label class="text-sm font-semibold select-none cursor-pointer">
Enable Multi-Day Selection
</label>
</div>
start_of_month = Date.beginning_of_month(current_month)
end_of_month = Date.end_of_month(current_month)
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl p-4 shadow-sm">
<div class="flex items-center justify-between mb-4">
<button phx-click="prev_month" phx-target={@myself} class="btn btn-sm btn-ghost btn-circle">
<.icon name="hero-chevron-left" />
</button>
<h4 class="font-bold text-lg capitalize">
{Calendar.strftime(@first_day_of_month, "%B %Y")}
</h4>
<button phx-click="next_month" phx-target={@myself} class="btn btn-sm btn-ghost btn-circle">
<.icon name="hero-chevron-right" />
</button>
</div>
# Single query for entire month
{:ok, bookings} =
BookingSystem.search_bookings(
space_id,
DateTime.new!(start_of_month, ~T[00:00:00]),
DateTime.new!(end_of_month, ~T[23:59:59]),
[:accepted],
[:start_datetime, :end_datetime]
)
<div class="grid grid-cols-7 mb-2 text-center text-xs font-bold text-slate-400 uppercase tracking-wider">
<span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span>
</div>
# Count bookings per day
day_data = compute_day_data(bookings, start_of_month, end_of_month)
<div class="grid grid-cols-7 gap-1 md:gap-2">
<%= for date <- @grid do %>
<% # Uses the booking_counts passed from parent
count = Map.get(@booking_counts, date, 0)
is_current = date.month == @first_day_of_month.month %>
# Build calendar grid
calendar_weeks = build_calendar_grid(start_of_month, end_of_month)
<div class={[day_classes(date, assigns), !is_current && "opacity-25 grayscale"]}>
<%!-- Header Row: Date & Badge --%>
<div class="flex justify-between items-start">
<span class="text-xs font-bold">{date.day}</span>
<%= if count > 0 and is_current do %>
<.link
navigate={~p"/admin/bookings?date=#{Date.to_string(date)}"}
class="badge badge-info badge-xs text-white font-bold hover:scale-110 transition-transform"
title={"#{count} bookings"}
>
{count}
</.link>
<% end %>
</div>
assign(socket,
day_data: day_data,
calendar_weeks: calendar_weeks,
month_name: Calendar.strftime(current_month, "%B %Y")
)
<%= if @multi_day_mode do %>
{if @start_date == date, do: echo_label("Start")}
{if @end_date == date, do: echo_label("End")}
<% end %>
<%= if Date.compare(date, Date.utc_today()) != :lt do %>
<button
phx-click="select_date"
phx-value-date={date}
phx-target={@myself}
class="absolute inset-0 w-full h-full"
>
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
"""
end
defp compute_day_data(bookings, start_date, end_date) do
# Initialize all days with zero count
date_range = Date.range(start_date, end_date)
initial_map =
Enum.reduce(date_range, %{}, fn date, acc ->
Map.put(acc, date, 0)
end)
# Count bookings for each day
Enum.reduce(bookings, initial_map, fn booking, acc ->
# Get all dates this booking spans
booking_start_date = DateTime.to_date(booking.start_datetime)
booking_end_date = DateTime.to_date(booking.end_datetime)
booking_dates = Date.range(booking_start_date, booking_end_date)
# Increment count for each day this booking touches
Enum.reduce(booking_dates, acc, fn date, inner_acc ->
case Map.get(inner_acc, date) do
# Date outside month range
nil -> inner_acc
count -> Map.put(inner_acc, date, count + 1)
end
end)
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, assigns) do
defp day_classes(date, %{
start_date: start_date,
end_date: end_date,
selected_date: selected_date,
multi_day_mode: multi
}) do
is_past = Date.compare(date, Date.utc_today()) == :lt
in_range = day_in_range?(date, assigns.selected_date, assigns.start_date, assigns.end_date)
is_start = is_start_date?(date, assigns.start_date, assigns.end_date)
is_end = is_end_date?(date, assigns.start_date, assigns.end_date)
is_start = start_date == date
is_end = end_date == date
is_sel = selected_date == date
in_range = CalendarExt.date_in_range?(date, start_date, end_date)
base = "relative aspect-square flex flex-col items-start justify-start p-2 transition-all"
base =
"relative aspect-square flex flex-col p-2 transition-all border border-slate-200 dark:border-slate-700 "
cond do
is_past ->
[base, "text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50"]
base <> "bg-slate-50 dark:bg-slate-800/50 text-slate-300 cursor-not-allowed"
in_range && assigns.multi_day_mode && 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_start ->
base <> "bg-primary text-white rounded-l-lg z-10 shadow-md"
is_end ->
[
base,
"rounded-r-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"
]
is_end ->
base <> "bg-primary text-white rounded-r-lg z-10 shadow-md"
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 && multi ->
base <> "bg-primary/20 text-slate-900 dark:text-white"
in_range ->
[
base,
"rounded-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"
]
is_sel ->
base <> "bg-primary text-white rounded-lg shadow-md"
true ->
[
base,
"rounded-lg bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-600 hover:border-primary dark:hover:border-primary"
]
base <> "bg-white dark:bg-slate-800 hover:bg-slate-50 text-slate-700 dark:text-slate-200"
end
end
defp echo_label(text) do
assigns = %{text: text}
~H"""
<span class="mt-auto text-[10px] uppercase font-black tracking-tighter">{@text}</span>
"""
end
end

View file

@ -1,137 +0,0 @@
<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 cursor-pointer"
phx-click="toggle_multi_day"
phx-target={@myself}
>
<input
type="checkbox"
id={"multi-day-#{@id}"}
checked={@multi_day_mode}
class="size-4 rounded border-slate-300 dark:border-slate-600 text-primary focus:ring-primary dark:bg-slate-700 pointer-events-none"
readonly
/>
<label
for={"multi-day-#{@id}"}
class="text-sm font-semibold text-slate-700 dark:text-slate-300 select-none flex-1"
>
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">
Booking Calendar
</h3>
</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 %>
<% count = Map.get(@day_data, day, 0) %>
<% is_past = Date.compare(day, Date.utc_today()) == :lt %>
<% 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_past, do: "select_date", else: nil}
phx-value-date={Date.to_iso8601(day)}
phx-target={@myself}
class={day_classes(day, assigns)}
disabled={is_past}
title={if is_past, do: "Past date", else: "Select date"}
>
<%!-- Booking count badge - floating in top right corner --%>
<%= if count > 0 && !is_past do %>
<.link
navigate={~p"/admin/bookings?date=#{Date.to_string(day)}"}
class="absolute -top-2 -right-2 z-20 flex items-center gap-1 px-2 py-1 rounded-full bg-info text-info-content shadow-lg hover:shadow-xl hover:scale-110 transition-all duration-200 ring-2 ring-white dark:ring-slate-800"
title={"View #{count} booking#{if count > 1, do: "s", else: ""}"}
>
<.icon name="hero-calendar-days" class="w-3 h-3" />
<span class="text-xs font-bold">{count}</span>
</.link>
<% end %>
<div class="flex flex-col items-start justify-start w-full h-full">
<span class={[
"text-xs",
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-auto">
Start
</span>
<% is_end && @multi_day_mode && @end_date != nil -> %>
<span class="text-[10px] uppercase font-bold tracking-tighter mt-auto">
End
</span>
<% true -> %>
<div class="flex-1"></div>
<% end %>
</div>
</button>
<% end %>
<% end %>
<% end %>
</div>
<%!-- Legend explaining booking counts --%>
<div class="mt-4 p-3 bg-base-200 rounded-lg">
<p class="text-sm text-base-content flex items-center gap-2">
<span class="flex items-center gap-1 px-2 py-1 rounded-full bg-info text-info-content shadow-md">
<.icon name="hero-calendar-days" class="w-3 h-3" />
<span class="text-xs font-bold">#</span>
</span>
<span>Click badge to view bookings for that day</span>
</p>
</div>
</div>
</div>

View file

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

View file

@ -40,6 +40,7 @@
module={SpazioSolazzoWeb.Admin.AdminCalendarComponent}
id="walk-in-calendar"
space_id={@space.id}
booking_counts={@booking_counts}
/>
</div>
@ -102,7 +103,7 @@
Daily Schedule
</h3>
<%= if @multi_day_mode && @start_date && @end_date do %>
<%= if @multi_day_mode do %>
<%!-- Multi-day mode: Show info card --%>
<div class="w-full bg-slate-50 dark:bg-slate-800/40 border border-slate-200 dark:border-slate-700 rounded-xl p-5 flex flex-col items-center text-center">
<div class="size-10 bg-primary/10 dark:bg-primary/20 text-primary rounded-full flex items-center justify-center mb-3">

View file

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

View file

@ -25,7 +25,7 @@
<div class="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<.live_component
module={SpazioSolazzoWeb.CalendarLiveComponent}
module={SpazioSolazzoWeb.BookingCalendarLiveComponent}
id="booking-calendar"
selected_date={@selected_date}
/>

View file

@ -658,6 +658,7 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
# Use future dates relative to today
today = Date.utc_today()
dates = [
Date.add(today, 1),
Date.add(today, 2),