mirror of
https://codeberg.org/JasterV/spazio-solazzo.git
synced 2026-04-26 18:20:03 +00:00
refactor: calendars to share logic and be more simple
This commit is contained in:
parent
4b79615a76
commit
c01bd8e733
10 changed files with 342 additions and 483 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue