feat: build an admin dashboard (#11)

* refactor: update button colors

* feat: add dashboard panel for admins only & update header styles
This commit is contained in:
Víctor Martínez 2026-02-01 19:30:58 +01:00 committed by GitHub
parent 961b24b202
commit bbc2f08215
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 451 additions and 173 deletions

View file

@ -220,3 +220,8 @@ h6 {
.hover\:text-plum:hover {
color: var(--color-primary);
}
/* Fix label-text color in dark mode */
.label-text {
color: var(--color-base-content);
}

View file

@ -135,6 +135,16 @@ defmodule SpazioSolazzo.Accounts.User do
end
end
# Make sure it is loaded in all read actions
preparations do
prepare build(load: [:is_admin])
end
# Make sure it is loaded in all write actions
changes do
change load(:is_admin)
end
attributes do
uuid_primary_key :id
@ -152,6 +162,17 @@ defmodule SpazioSolazzo.Accounts.User do
allow_nil? true
public? true
end
attribute :role, :atom do
allow_nil? false
public? false
constraints one_of: [:customer, :admin]
default :customer
end
end
calculations do
calculate :is_admin, :boolean, expr(role == :admin)
end
identities do

View file

@ -0,0 +1,38 @@
defmodule SpazioSolazzoWeb.AdminComponents do
@moduledoc """
Reusable components for admin pages (dashboard & tools).
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1, button: 1]
import Phoenix.Component
use Phoenix.VerifiedRoutes,
endpoint: SpazioSolazzoWeb.Endpoint,
router: SpazioSolazzoWeb.Router,
statics: SpazioSolazzoWeb.static_paths()
@doc """
Cards displayed for each tool available to admins
"""
attr :id, :string, doc: "optional id for the tool"
attr :title, :string
attr :icon, :string, doc: "Icon used to represent the type of tool"
attr :description, :string
def tool_card(assigns) do
~H"""
<div class="card bg-base-100 text-base-content shadow-xl border border-base-200 hover:shadow-2xl transition-shadow cursor-pointer">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<.icon name={@icon} class="size-6 text-secondary" /> {@title}
</h2>
<p class="text-base-content/70 mt-2">{@description}</p>
<div class="card-actions justify-end mt-4">
<.button class="btn btn-primary btn-sm">Open</.button>
</div>
</div>
</div>
"""
end
end

View file

@ -31,6 +31,26 @@ defmodule SpazioSolazzoWeb.CoreComponents do
alias Phoenix.LiveView.JS
@doc """
Renders a back-to navigation link
"""
attr :navigate, :string
attr :value, :string
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
def back_to_link(assigns) do
~H"""
<.link
class="mb-6 text-sm font-medium text-neutral hover:text-secondary transition-colors inline-flex items-center gap-2"
navigate={@navigate}
{@rest}
>
<.icon name="hero-arrow-left" class="w-5 h-5" />{@value}
</.link>
"""
end
@doc """
Renders flash notices.
@ -223,7 +243,8 @@ defmodule SpazioSolazzoWeb.CoreComponents do
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
/>
<span class="label-text">{@label}</span>
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>

View file

@ -4,7 +4,7 @@ defmodule SpazioSolazzoWeb.LandingComponents do
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1, back_to_link: 1]
import Phoenix.Component
use Phoenix.VerifiedRoutes,
@ -131,14 +131,10 @@ defmodule SpazioSolazzoWeb.LandingComponents do
~H"""
<section class="relative pt-6 md:pt-10 pb-16 px-6 bg-base-100">
<div class="mx-auto max-w-[1200px]">
<div class="mb-6 flex items-center gap-2 text-sm text-neutral">
<.link
navigate={~p"/"}
class="hover:text-primary transition-colors flex items-center gap-1"
>
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Home
</.link>
</div>
<.back_to_link
navigate={~p"/"}
value="Back to Home"
/>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-center">
<div class="order-2 lg:order-1 flex flex-col gap-6">
<div>

View file

@ -38,8 +38,85 @@ defmodule SpazioSolazzoWeb.Layouts do
slot :inner_block, required: true
def app(assigns) do
current_year = Date.utc_today().year
assigns = assign(assigns, :current_year, current_year)
~H"""
<.app_header current_user={@current_user} />
<header class="sticky top-0 z-50 navbar bg-base-100 shadow-sm dark:shadow-white/40 pr-4 pl-4 text-base-content">
<div class="navbar-start">
<.link navigate="/" class="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src="/images/logo.png" alt="Spazio Solazzo" class="h-8" />
</.link>
</div>
<div class="navbar-center hidden md:flex">
<ul class="menu menu-horizontal px-1">
<%= if @current_user && @current_user.is_admin do %>
<li>
<.link class="dark:hover:bg-secondary/20" navigate={~p"/admin/dashboard"}>
Dashboard
</.link>
</li>
<% end %>
</ul>
</div>
<div class="navbar-end flex gap-3">
<.theme_toggle />
<%= if @current_user do %>
<%!-- Desktop Menu --%>
<div class="hidden md:flex items-center gap-3">
<.link
navigate={~p"/profile"}
class="btn btn-circle btn-outline text-secondary hover:bg-info/10"
aria-label="Profile"
>
<.icon name="hero-user" class="size-6" />
</.link>
</div>
<% else %>
<.link navigate={~p"/sign-in"} id="sign-in-link" class="btn btn-secondary btn-sm">
Sign In
</.link>
<% end %>
<%!-- Mobile menu --%>
<%= if @current_user do %>
<div class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-ghost p-2 hover:bg-secondary/20 border-none"
>
<.menu_svg />
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow dark:shadow-none dark:border dark:border-white/40 bg-base-100 rounded-box w-52"
>
<%= if @current_user && @current_user.is_admin do %>
<li class="md:hidden">
<.link navigate={~p"/admin/dashboard"}>
<.icon name="hero-squares-2x2" class="size-4" /> Dashboard
</.link>
</li>
<% end %>
<li class="md:hidden">
<.link navigate={~p"/profile"}>
<.icon name="hero-user" class="size-4" /> Profile
</.link>
</li>
<li>
<.link href={~p"/sign-out"} id="mobile-sign-out-link" class="text-error">
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Sign Out
</.link>
</li>
</ul>
</div>
<% end %>
</div>
</header>
<main class="bg-base-100 flex-1 relative transition-colors duration-300">
{render_slot(@inner_block)}
@ -47,156 +124,6 @@ defmodule SpazioSolazzoWeb.Layouts do
<.flash_group flash={@flash} />
<.footer />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<button
class="p-2 rounded-full hover:bg-base-200 text-neutral transition-colors"
phx-click={
JS.dispatch("phx:set-theme",
detail: %{theme: "toggle"}
)
}
title="Toggle Dark Mode"
>
<.icon name="hero-sun" class="size-5 [[data-theme=dark]_&]:hidden" />
<.icon name="hero-moon" class="size-5 hidden [[data-theme=dark]_&]:block" />
</button>
"""
end
attr :current_user, :map,
default: nil,
doc: "the current authenticated user"
defp app_header(assigns) do
~H"""
<header class="sticky top-0 z-50 w-full border-b border-base-200 bg-base-100 backdrop-blur-md px-6 py-4">
<div class="mx-auto flex h-10 max-w-[1200px] items-center justify-between">
<.link navigate="/" class="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src="/images/logo.png" alt="Spazio Solazzo" class="h-8" />
</.link>
<div class="flex items-center gap-4">
<.theme_toggle />
<%= if @current_user do %>
<%!-- Desktop menu --%>
<div class="hidden md:flex items-center gap-3">
<.link
navigate={~p"/profile"}
class="btn btn-circle btn-outline text-primary hover:bg-info/10"
>
<.icon name="hero-user" class="size-6" />
</.link>
<.link
href={~p"/sign-out"}
id="sign-out-link"
class="btn btn-outline btn-error btn-sm hover:text-error hover:bg-error/10"
>
Sign Out
</.link>
</div>
<%!-- Mobile menu button --%>
<button
phx-click={JS.toggle(to: "#mobile-menu")}
class="btn btn-ghost btn-sm md:hidden text-neutral"
id="mobile-menu-button"
>
<.icon name="hero-bars-3" class="size-6" />
</button>
<% else %>
<.link navigate={~p"/sign-in"} id="sign-in-link" class="btn btn-secondary btn-sm">
Sign In
</.link>
<% end %>
</div>
</div>
<%!-- Mobile dropdown menu --%>
<%= if @current_user do %>
<div
id="mobile-menu"
class="md:hidden absolute top-full right-0 left-0 mt-2 mx-6 bg-base-100 border border-base-200 rounded-xl shadow-lg overflow-hidden"
style="display: none;"
>
<div class="menu">
<.link
navigate={~p"/profile"}
phx-click={JS.hide(to: "#mobile-menu")}
class="flex items-center gap-3 px-4 py-3 text-sm font-medium hover:bg-accent/10 text-neutral transition-colors"
>
<.icon name="hero-user" class="size-5 text-primary" /> Profile
</.link>
<.link
href={~p"/sign-out"}
id="mobile-sign-out-link"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium hover:bg-error/10 text-error transition-colors border-t border-base-200"
>
<.icon name="hero-arrow-right-on-rectangle" class="size-5" /> Sign Out
</.link>
</div>
</div>
<% end %>
</header>
"""
end
defp footer(assigns) do
current_year = Date.utc_today().year
assigns = assign(assigns, :current_year, current_year)
~H"""
<footer class="footer border-t border-base-200 bg-base-100 py-12 px-6">
<div class="mx-auto max-w-[1200px] w-full flex flex-col md:flex-row justify-between gap-8">
<div class="flex flex-col gap-4 max-w-sm">
@ -286,4 +213,88 @@ defmodule SpazioSolazzoWeb.Layouts do
</footer>
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<button
class="p-2 rounded-full hover:bg-base-200 text-neutral transition-colors cursor-pointer"
phx-click={
JS.dispatch("phx:set-theme",
detail: %{theme: "toggle"}
)
}
title="Toggle Dark Mode"
>
<.icon name="hero-sun" class="size-5 [[data-theme=dark]_&]:hidden" />
<.icon name="hero-moon" class="size-5 hidden [[data-theme=dark]_&]:block" />
</button>
"""
end
defp menu_svg(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
"""
end
end

View file

@ -0,0 +1,21 @@
defmodule SpazioSolazzoWeb.Admin.DashboardLive do
@moduledoc """
Admin dashboard home page. Lists the available tools that admins have
"""
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.AdminComponents
def mount(_params, _session, socket) do
{:ok, coworking_space} = BookingSystem.get_space_by_slug("coworking", not_found_error?: false)
{:ok, meeting_space} = BookingSystem.get_space_by_slug("meeting", not_found_error?: false)
{:ok,
assign(socket,
coworking_space: coworking_space,
meeting_space: meeting_space
)}
end
end

View file

@ -0,0 +1,28 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="mx-auto max-w-[1200px] px-6 py-12">
<.back_to_link
navigate={~p"/"}
value="Back to Home"
/>
<h1 class="text-3xl text-base-content font-bold mb-8">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<%= if @meeting_space do %>
<.tool_card
title={@meeting_space.name}
description="Create walk-in bookings for the space"
icon="hero-user-group"
/>
<% end %>
<%= if @coworking_space do %>
<.tool_card
title={@coworking_space.name}
description="Create walk-in bookings for the space"
icon="hero-user-group"
/>
<% end %>
</div>
</div>
</Layouts.app>

View file

@ -1,12 +1,10 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<section class="mx-auto max-w-[1200px] px-6 py-10">
<div class="mb-10">
<.link
<div class="mb-4">
<.back_to_link
navigate={"/#{@space.slug}"}
class="inline-flex items-center gap-2 text-sm font-medium text-neutral hover:text-secondary transition-colors"
>
<.icon name="hero-arrow-left" class="w-5 h-5" /> Back to {@space.name}
</.link>
value={"Back to #{@space.name}"}
/>
</div>
<div class="text-center mb-12">

View file

@ -118,14 +118,14 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
<div class="mt-6 flex items-center gap-3">
<button
type="submit"
class="btn btn-primary flex-1 rounded-2xl"
class="btn btn-secondary flex-1 rounded-2xl"
>
Confirm
</button>
<button
type="button"
phx-click={@on_cancel}
class="btn btn-ghost btn-secondary dark:text-white flex-1 rounded-2xl"
class="btn btn-ghost btn-primary dark:text-white flex-1 rounded-2xl"
>
Cancel
</button>

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="relative flex min-h-screen flex-col bg-base-100">
<main class="flex-1 bg-base-100">
<div class="flex-1 bg-base-100">
<section class="bg-base-100">
<div class="mx-auto max-w-[1200px] px-6 pt-25 pb-20 text-center bg-base-100">
<div class="flex justify-center">
@ -100,6 +100,6 @@
<% end %>
</div>
</section>
</main>
</div>
</div>
</Layouts.app>

View file

@ -1,5 +1,12 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="mx-auto max-w-[800px] px-6 py-12">
<div class="mb-10">
<.back_to_link
navigate={~p"/"}
value="Back to Home"
/>
</div>
<div class="mb-10 text-center">
<h1 class="text-4xl font-black text-base-content tracking-tight">
User Profile
@ -71,7 +78,7 @@
<div class="mt-8 pt-8 border-t border-base-200 flex justify-end">
<button
type="submit"
class="btn btn-primary rounded-xl shadow-lg cursor-pointer"
class="btn btn-secondary rounded-xl shadow-lg cursor-pointer"
>
Save Changes
</button>
@ -165,7 +172,7 @@
<button
type="button"
phx-click="hide_delete_modal"
class="btn btn-ghost rounded-xl cursor-pointer"
class="btn btn-ghost text-base-content rounded-xl cursor-pointer"
>
Cancel
</button>

View file

@ -36,4 +36,14 @@ defmodule SpazioSolazzoWeb.LiveUserAuth do
{:cont, assign(socket, :current_user, nil)}
end
end
def on_mount(:live_admin_required, _params, _session, socket) do
case socket.assigns[:current_user] do
%{is_admin: true} ->
{:cont, socket}
_ ->
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
end
end
end

View file

@ -56,6 +56,13 @@ defmodule SpazioSolazzoWeb.Router do
live "/book/asset/:asset_id", AssetBookingLive
live "/profile", ProfileLive
end
ash_authentication_live_session :admin_routes,
on_mount: [
{SpazioSolazzoWeb.LiveUserAuth, :live_admin_required}
] do
live "/admin/dashboard", Admin.DashboardLive
end
end
# Enable LiveDashboard and Swoosh mailbox preview in development

View file

@ -0,0 +1,21 @@
defmodule SpazioSolazzo.Repo.Migrations.AddRoleToUsers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
add :role, :text, null: false, default: "customer"
end
end
def down do
alter table(:users) do
remove :role
end
end
end

View file

@ -0,0 +1,94 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"customer\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "role",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "0B279BA9251EA91451BB80DF86AF81ECCA5395011864BE01BE1C369420AD9C18",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "users"
}