| 1 |
:-( |
defmodule DaProductAppWeb.DashboardLive do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Real-time Dashboard LiveView with role-based access and live updates. |
| 4 |
|
""" |
| 5 |
|
use DaProductAppWeb, :live_view |
| 6 |
|
import Phoenix.LiveView.Helpers |
| 7 |
|
import DaProductAppWeb.CoreComponents |
| 8 |
|
alias DaProductApp.Accounts |
| 9 |
|
alias DaProductApp.Monitoring |
| 10 |
|
|
| 11 |
|
@impl true |
| 12 |
:-( |
def mount(_params, session, socket) do |
| 13 |
:-( |
case get_current_user(session) do |
| 14 |
:-( |
nil -> |
| 15 |
|
{:ok, redirect(socket, to: ~p"/login")} |
| 16 |
|
|
| 17 |
|
user -> |
| 18 |
:-( |
if has_access?(user) do |
| 19 |
:-( |
if connected?(socket) do |
| 20 |
|
# Subscribe to real-time updates |
| 21 |
:-( |
Phoenix.PubSub.subscribe(DaProductApp.PubSub, "dashboard_updates") |
| 22 |
:-( |
Phoenix.PubSub.subscribe(DaProductApp.PubSub, "transaction_updates") |
| 23 |
|
|
| 24 |
|
# Set up periodic updates |
| 25 |
:-( |
:timer.send_interval(30_000, self(), :refresh_stats) |
| 26 |
|
end |
| 27 |
|
|
| 28 |
:-( |
socket = |
| 29 |
|
socket |
| 30 |
|
|> assign(:current_user, user) |
| 31 |
|
|> assign(:page_title, get_page_title()) |
| 32 |
|
|> assign(:current_page, :dashboard) |
| 33 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 34 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 35 |
|
|> assign(:activities, load_recent_activities(user)) |
| 36 |
|
|> assign(:quick_actions, get_quick_actions(user)) |
| 37 |
|
|
| 38 |
|
{:ok, socket} |
| 39 |
|
else |
| 40 |
|
{:ok, |
| 41 |
|
socket |
| 42 |
|
|> put_flash(:error, "Access denied") |
| 43 |
|
|> redirect(to: ~p"/login")} |
| 44 |
|
end |
| 45 |
|
end |
| 46 |
|
end |
| 47 |
|
|
| 48 |
:-( |
def get_page_title(), do: "Dashboard" |
| 49 |
|
|
| 50 |
|
def mount_with_user(socket, user) do |
| 51 |
:-( |
if connected?(socket) do |
| 52 |
|
# Subscribe to real-time updates |
| 53 |
:-( |
Phoenix.PubSub.subscribe(DaProductApp.PubSub, "dashboard_updates") |
| 54 |
:-( |
Phoenix.PubSub.subscribe(DaProductApp.PubSub, "transaction_updates") |
| 55 |
|
|
| 56 |
|
# Set up periodic updates |
| 57 |
:-( |
:timer.send_interval(30_000, self(), :refresh_stats) |
| 58 |
|
end |
| 59 |
|
|
| 60 |
:-( |
socket = |
| 61 |
|
socket |
| 62 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 63 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 64 |
|
|> assign(:activities, load_recent_activities(user)) |
| 65 |
|
|> assign(:quick_actions, get_quick_actions(user)) |
| 66 |
|
|
| 67 |
:-( |
socket |
| 68 |
|
end |
| 69 |
|
|
| 70 |
|
@impl true |
| 71 |
:-( |
def handle_info(:refresh_stats, socket) do |
| 72 |
:-( |
user = socket.assigns.current_user |
| 73 |
|
|
| 74 |
|
{:noreply, |
| 75 |
|
socket |
| 76 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 77 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 78 |
|
|> assign(:activities, load_recent_activities(user))} |
| 79 |
|
end |
| 80 |
|
|
| 81 |
|
@impl true |
| 82 |
:-( |
def handle_info({:transaction_update, _transaction}, socket) do |
| 83 |
:-( |
user = socket.assigns.current_user |
| 84 |
|
|
| 85 |
|
{:noreply, |
| 86 |
|
socket |
| 87 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 88 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 89 |
|
|> assign(:activities, load_recent_activities(user))} |
| 90 |
|
end |
| 91 |
|
|
| 92 |
|
@impl true |
| 93 |
:-( |
def handle_info({:req_pay_update, _req_pay}, socket) do |
| 94 |
:-( |
user = socket.assigns.current_user |
| 95 |
|
|
| 96 |
|
{:noreply, |
| 97 |
|
socket |
| 98 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 99 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 100 |
|
|> assign(:activities, load_recent_activities(user))} |
| 101 |
|
end |
| 102 |
|
|
| 103 |
|
@impl true |
| 104 |
:-( |
def handle_info({:dashboard_update, _payload}, socket) do |
| 105 |
:-( |
user = socket.assigns.current_user |
| 106 |
|
|
| 107 |
|
{:noreply, |
| 108 |
|
socket |
| 109 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 110 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 111 |
|
|> assign(:activities, load_recent_activities(user))} |
| 112 |
|
end |
| 113 |
|
|
| 114 |
|
@impl true |
| 115 |
:-( |
def handle_event("refresh_dashboard", _params, socket) do |
| 116 |
:-( |
user = socket.assigns.current_user |
| 117 |
|
|
| 118 |
|
{:noreply, |
| 119 |
|
socket |
| 120 |
|
|> assign(:stats, load_dashboard_stats(user)) |
| 121 |
|
|> assign(:recent_transactions, load_recent_transactions(user)) |
| 122 |
|
|> assign(:activities, load_recent_activities(user)) |
| 123 |
|
|> put_flash(:info, "Dashboard refreshed successfully!")} |
| 124 |
|
end |
| 125 |
|
|
| 126 |
|
# Handle theme switching |
| 127 |
:-( |
def handle_event("toggle_theme", %{"theme" => _theme}, socket) do |
| 128 |
|
{:noreply, socket} |
| 129 |
|
end |
| 130 |
|
|
| 131 |
|
# Handle the hide user menu event |
| 132 |
:-( |
def handle_event("hide_user_menu", _params, socket) do |
| 133 |
|
{:noreply, socket} |
| 134 |
|
end |
| 135 |
|
|
| 136 |
|
# Private helper functions |
| 137 |
|
defp load_dashboard_stats(_user) do |
| 138 |
|
# Fetch analytics from Monitoring context |
| 139 |
:-( |
analytics = Monitoring.get_dashboard_analytics() |
| 140 |
|
|
| 141 |
:-( |
tx_stats = Map.get(analytics, :transactions, %{}) |
| 142 |
:-( |
req_pay_stats = Map.get(analytics, :req_pays, %{}) |
| 143 |
|
|
| 144 |
:-( |
total_tx = Map.get(tx_stats, :total_count, 0) |
| 145 |
:-( |
success_rate = Map.get(tx_stats, :success_rate, 0.0) |
| 146 |
|
|
| 147 |
|
# Count active users (simple implementation). |
| 148 |
:-( |
total_users = Accounts.list_users() |> length() |
| 149 |
|
|
| 150 |
|
# Compute total revenue from req_pays corridor_stats (sum of amounts) |
| 151 |
:-( |
total_revenue = |
| 152 |
|
req_pay_stats |
| 153 |
|
|> Map.get(:corridor_stats, []) |
| 154 |
|
|> Enum.reduce(Decimal.new(0), fn |
| 155 |
:-( |
{_corridor, _count, amt}, acc when is_binary(amt) -> Decimal.add(acc, Decimal.new(amt)) |
| 156 |
:-( |
{_corridor, _count, %Decimal{} = amt}, acc -> Decimal.add(acc, amt) |
| 157 |
:-( |
{_corridor, _count, nil}, acc -> acc |
| 158 |
:-( |
{_corridor, _count, amt}, acc when is_number(amt) -> Decimal.add(acc, Decimal.new(amt)) |
| 159 |
|
end) |
| 160 |
|
|
| 161 |
:-( |
%{ |
| 162 |
|
total_transactions: %{ |
| 163 |
|
title: "Total Transactions", |
| 164 |
|
value: Integer.to_string(total_tx), |
| 165 |
|
icon: "credit-card", |
| 166 |
|
trend: nil, |
| 167 |
|
color: "blue" |
| 168 |
|
}, |
| 169 |
|
total_users: %{ |
| 170 |
|
title: "Active Users", |
| 171 |
|
value: Integer.to_string(total_users), |
| 172 |
|
icon: "users", |
| 173 |
|
trend: nil, |
| 174 |
|
color: "green" |
| 175 |
|
}, |
| 176 |
|
total_revenue: %{ |
| 177 |
|
title: "Revenue", |
| 178 |
|
value: "₹" <> Decimal.to_string(total_revenue, :normal), |
| 179 |
|
icon: "chart-bar", |
| 180 |
|
trend: nil, |
| 181 |
|
color: "yellow" |
| 182 |
|
}, |
| 183 |
|
success_rate: %{ |
| 184 |
|
title: "Success Rate", |
| 185 |
|
value: :erlang.float_to_binary(success_rate, [decimals: 2]) <> "%", |
| 186 |
|
icon: "check-circle", |
| 187 |
|
trend: nil, |
| 188 |
|
color: "green" |
| 189 |
|
} |
| 190 |
|
} |
| 191 |
|
end |
| 192 |
|
|
| 193 |
|
defp load_recent_transactions(_user) do |
| 194 |
|
# Load recent transactions from Monitoring for the last 24 hours (limit 5) |
| 195 |
:-( |
now = DateTime.utc_now() |
| 196 |
:-( |
date_from = DateTime.add(now, -86_400, :second) |
| 197 |
|
|
| 198 |
:-( |
transactions = |
| 199 |
|
Monitoring.list_transactions(page_size: 5, date_from: date_from, date_to: now) |
| 200 |
|
|
| 201 |
:-( |
Enum.map(transactions, fn t -> |
| 202 |
:-( |
%{ |
| 203 |
:-( |
db_id: t.id, |
| 204 |
:-( |
id: t.org_txn_id || "TXN#{t.id}", |
| 205 |
:-( |
amount: if(t.inr_amount, do: "₹" <> format_amount_for_dashboard(t.inr_amount), else: "N/A"), |
| 206 |
:-( |
status: t.status || "unknown", |
| 207 |
:-( |
type: t.transaction_type || "N/A", |
| 208 |
:-( |
merchant: t.payee_name || t.payee_addr || t.payee_mid || "N/A", |
| 209 |
:-( |
description: (t.payee_name || t.payee_addr || "Payment"), |
| 210 |
:-( |
time: relative_time(t.inserted_at, now) |
| 211 |
|
} |
| 212 |
|
end) |
| 213 |
|
end |
| 214 |
|
|
| 215 |
|
# Format amount for dashboard (simple, round to 2 decimals) |
| 216 |
|
defp format_amount_for_dashboard(%Decimal{} = d) do |
| 217 |
|
d |
| 218 |
|
|> Decimal.round(2) |
| 219 |
:-( |
|> Decimal.to_string(:normal) |
| 220 |
|
end |
| 221 |
|
|
| 222 |
:-( |
defp format_amount_for_dashboard(amount) when is_binary(amount), do: amount |
| 223 |
:-( |
defp format_amount_for_dashboard(amount) when is_number(amount), do: :erlang.float_to_binary(amount, decimals: 2) |
| 224 |
|
|
| 225 |
|
# human readable relative time |
| 226 |
:-( |
defp relative_time(nil, _now), do: "N/A" |
| 227 |
|
defp relative_time(%NaiveDateTime{} = ndt, now) do |
| 228 |
:-( |
dt = DateTime.from_naive!(ndt, "Etc/UTC") |
| 229 |
:-( |
relative_time(dt, now) |
| 230 |
|
end |
| 231 |
|
|
| 232 |
|
defp relative_time(%DateTime{} = dt, now) do |
| 233 |
:-( |
seconds = DateTime.diff(now, dt, :second) |
| 234 |
:-( |
cond do |
| 235 |
:-( |
seconds < 60 -> "#{seconds}s ago" |
| 236 |
:-( |
seconds < 3600 -> "#{div(seconds, 60)} mins ago" |
| 237 |
:-( |
seconds < 86_400 -> "#{div(seconds, 3600)} hours ago" |
| 238 |
:-( |
true -> "#{div(seconds, 86_400)} days ago" |
| 239 |
|
end |
| 240 |
|
end |
| 241 |
|
|
| 242 |
:-( |
defp load_recent_activities(_user) do |
| 243 |
|
[ |
| 244 |
|
%{ |
| 245 |
|
title: "New merchant onboarded", |
| 246 |
|
description: "ABC Electronics registered successfully", |
| 247 |
|
time: "10 mins ago", |
| 248 |
|
icon: "user-plus", |
| 249 |
|
status: "success" |
| 250 |
|
}, |
| 251 |
|
%{ |
| 252 |
|
title: "Payment gateway maintenance", |
| 253 |
|
description: "Scheduled maintenance completed", |
| 254 |
|
time: "1 hour ago", |
| 255 |
|
icon: "cog", |
| 256 |
|
status: "info" |
| 257 |
|
}, |
| 258 |
|
%{ |
| 259 |
|
title: "High volume alert", |
| 260 |
|
description: "Transaction volume exceeded threshold", |
| 261 |
|
time: "2 hours ago", |
| 262 |
|
icon: "exclamation", |
| 263 |
|
status: "warning" |
| 264 |
|
} |
| 265 |
|
] |
| 266 |
|
end |
| 267 |
|
|
| 268 |
:-( |
defp get_quick_actions(_user) do |
| 269 |
|
[ |
| 270 |
|
|
| 271 |
|
%{ |
| 272 |
|
title: "View Reports", |
| 273 |
|
description: "Generate analytics reports", |
| 274 |
|
path: "/reports", |
| 275 |
|
icon: "chart-bar" |
| 276 |
|
}, |
| 277 |
|
%{ |
| 278 |
|
title: "Manage Users", |
| 279 |
|
description: "User management panel", |
| 280 |
|
path: "/users", |
| 281 |
|
icon: "users" |
| 282 |
|
}, |
| 283 |
|
%{ |
| 284 |
|
title: "System Settings", |
| 285 |
|
description: "Configure system settings", |
| 286 |
|
path: "/settings", |
| 287 |
|
icon: "cog" |
| 288 |
|
} |
| 289 |
|
] |
| 290 |
|
end |
| 291 |
|
|
| 292 |
|
# Component definitions |
| 293 |
|
attr :title, :string, required: true |
| 294 |
|
attr :value, :string, required: true |
| 295 |
|
attr :icon, :string, required: true |
| 296 |
|
attr :trend, :map, default: nil |
| 297 |
|
attr :color, :string, default: "blue" |
| 298 |
|
|
| 299 |
|
def stat_card(assigns) do |
| 300 |
:-( |
~H""" |
| 301 |
|
<div class="stats shadow bg-white"> |
| 302 |
|
<div class="stat"> |
| 303 |
|
<div class="stat-figure"> |
| 304 |
|
<div class={[ |
| 305 |
|
"w-8 h-8 rounded-md flex items-center justify-center", |
| 306 |
:-( |
case @color do |
| 307 |
:-( |
"blue" -> "bg-blue-500" |
| 308 |
:-( |
"green" -> "bg-green-500" |
| 309 |
:-( |
"yellow" -> "bg-yellow-500" |
| 310 |
:-( |
"red" -> "bg-red-500" |
| 311 |
:-( |
_ -> "bg-gray-500" |
| 312 |
|
end |
| 313 |
|
]}> |
| 314 |
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 315 |
:-( |
<%= case @icon do %> |
| 316 |
|
<% "credit-card" -> %> |
| 317 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"></path> |
| 318 |
|
<% "users" -> %> |
| 319 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path> |
| 320 |
|
<% "chart-bar" -> %> |
| 321 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> |
| 322 |
|
<% "check-circle" -> %> |
| 323 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> |
| 324 |
|
<% _ -> %> |
| 325 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> |
| 326 |
|
<% end %> |
| 327 |
|
</svg> |
| 328 |
|
</div> |
| 329 |
|
</div> |
| 330 |
:-( |
<div class="stat-title"><%= @title %></div> |
| 331 |
:-( |
<div class="stat-value text-lg"><%= @value %></div> |
| 332 |
:-( |
<%= if @trend do %> |
| 333 |
|
<div class="stat-desc"> |
| 334 |
|
<span class={[ |
| 335 |
|
"font-medium", |
| 336 |
:-( |
if(@trend.direction == "up", do: "text-green-600", else: "text-red-600") |
| 337 |
|
]}> |
| 338 |
:-( |
<%= @trend.value %> |
| 339 |
|
</span> |
| 340 |
|
<span class="text-gray-500"> from last month</span> |
| 341 |
|
</div> |
| 342 |
|
<% end %> |
| 343 |
|
</div> |
| 344 |
|
</div> |
| 345 |
|
""" |
| 346 |
|
end |
| 347 |
|
|
| 348 |
|
# Modern Quick Action Card Component |
| 349 |
|
attr :title, :string, required: true |
| 350 |
|
attr :description, :string, required: true |
| 351 |
|
attr :path, :string, required: true |
| 352 |
|
attr :icon, :string, required: true |
| 353 |
|
|
| 354 |
|
def modern_quick_action_card(assigns) do |
| 355 |
:-( |
~H""" |
| 356 |
:-( |
<a href={@path} class="btn btn-outline btn-sm justify-start h-auto p-4 normal-case hover:btn-primary group"> |
| 357 |
|
<div class="flex items-center space-x-3 w-full"> |
| 358 |
|
<div class="flex-shrink-0"> |
| 359 |
|
<div class="w-8 h-8 bg-primary/10 group-hover:bg-primary/20 rounded-lg flex items-center justify-center transition-colors"> |
| 360 |
|
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 361 |
:-( |
<%= case @icon do %> |
| 362 |
|
<% "plus" -> %> |
| 363 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path> |
| 364 |
|
<% "chart-bar" -> %> |
| 365 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> |
| 366 |
|
<% "users" -> %> |
| 367 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path> |
| 368 |
|
<% "cog" -> %> |
| 369 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> |
| 370 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> |
| 371 |
|
<% _ -> %> |
| 372 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> |
| 373 |
|
<% end %> |
| 374 |
|
</svg> |
| 375 |
|
</div> |
| 376 |
|
</div> |
| 377 |
|
<div class="flex-1 text-left"> |
| 378 |
:-( |
<p class="font-medium text-sm"><%= @title %></p> |
| 379 |
:-( |
<p class="text-xs opacity-70"><%= @description %></p> |
| 380 |
|
</div> |
| 381 |
|
</div> |
| 382 |
|
</a> |
| 383 |
|
""" |
| 384 |
|
end |
| 385 |
|
|
| 386 |
|
# Modern Activity Item Component |
| 387 |
|
attr :title, :string, required: true |
| 388 |
|
attr :description, :string, required: true |
| 389 |
|
attr :time, :string, required: true |
| 390 |
|
attr :icon, :string, required: true |
| 391 |
|
attr :status, :string, default: "info" |
| 392 |
|
|
| 393 |
|
def modern_activity_item(assigns) do |
| 394 |
:-( |
~H""" |
| 395 |
|
<div class="flex items-start space-x-3 p-3 bg-base-200/30 rounded-lg"> |
| 396 |
|
<div class="flex-shrink-0"> |
| 397 |
|
<div class={[ |
| 398 |
|
"w-8 h-8 rounded-full flex items-center justify-center", |
| 399 |
:-( |
@status == "success" && "bg-success/20 text-success", |
| 400 |
:-( |
@status == "warning" && "bg-warning/20 text-warning", |
| 401 |
:-( |
@status == "info" && "bg-info/20 text-info", |
| 402 |
:-( |
@status == "error" && "bg-error/20 text-error" |
| 403 |
|
]}> |
| 404 |
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 405 |
:-( |
<%= case @icon do %> |
| 406 |
|
<% "user-plus" -> %> |
| 407 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path> |
| 408 |
|
<% "cog" -> %> |
| 409 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> |
| 410 |
|
<% "exclamation" -> %> |
| 411 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> |
| 412 |
|
<% _ -> %> |
| 413 |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> |
| 414 |
|
<% end %> |
| 415 |
|
</svg> |
| 416 |
|
</div> |
| 417 |
|
</div> |
| 418 |
|
<div class="flex-1 min-w-0"> |
| 419 |
:-( |
<p class="text-sm font-medium text-base-content"><%= @title %></p> |
| 420 |
:-( |
<p class="text-sm text-base-content/70"><%= @description %></p> |
| 421 |
:-( |
<p class="text-xs text-base-content/50 mt-1"><%= @time %></p> |
| 422 |
|
</div> |
| 423 |
|
</div> |
| 424 |
|
""" |
| 425 |
|
end |
| 426 |
|
|
| 427 |
|
# Helper functions for authentication and access control |
| 428 |
:-( |
defp get_current_user(session) do |
| 429 |
:-( |
case session do |
| 430 |
|
%{"user_id" => user_id} when is_binary(user_id) or is_integer(user_id) -> |
| 431 |
:-( |
Accounts.get_user!(user_id) |
| 432 |
:-( |
_ -> |
| 433 |
|
nil |
| 434 |
|
end |
| 435 |
|
rescue |
| 436 |
:-( |
_ -> nil |
| 437 |
|
end |
| 438 |
|
|
| 439 |
:-( |
defp has_access?(_user), do: true |
| 440 |
|
end |