- Client IP: only trust X-Forwarded-For from RFC 1918 proxy IPs - Webhook auth: handle nil/blank secret with controlled error, not 500 crash - Sudo redirect: reuse validated return_path? from login to prevent open redirect - Map live: remove redundant inline script (ensureLeaflet hook handles loading) - Bang calls: convert crash-prone exact matches to case in QR live and API controllers
164 lines
6.6 KiB
Text
164 lines
6.6 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="sites-map"
|
|
>
|
|
<.header>
|
|
<span class="flex items-center gap-2">
|
|
{t("Sites Map")}
|
|
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
|
|
{t("Geographic")}
|
|
</span>
|
|
</span>
|
|
<:subtitle>{t("Geographic locations of your sites")}</:subtitle>
|
|
<:actions>
|
|
<.button phx-click="refresh_map">
|
|
<.icon name="hero-arrow-path" class="h-5 w-5" /> Refresh
|
|
</.button>
|
|
<.button navigate={~p"/sites/new"} variant="primary">
|
|
<.icon name="hero-plus" class="h-5 w-5" /> Add Site
|
|
</.button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div class="mb-6">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
|
<div class="p-4 border-b border-gray-200 dark:border-white/10">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{t("Geographic Site Locations")}
|
|
</h3>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{t("Click markers to view site details")}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
Showing {length(@sites)} sites with coordinates
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Container -->
|
|
<div
|
|
id="leaflet-map"
|
|
phx-hook="SitesMap"
|
|
phx-update="ignore"
|
|
class="w-full"
|
|
style="height: 600px;"
|
|
data-sites={Jason.encode!(@sites)}
|
|
>
|
|
<!-- Loading State -->
|
|
<div class="flex items-center justify-center h-full">
|
|
<div class="text-center">
|
|
<.icon name="hero-arrow-path" class="h-8 w-8 text-gray-400 animate-spin mx-auto" />
|
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats and Site List -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<!-- Site Statistics -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 p-6">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">{t("Statistics")}</h3>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Sites</div>
|
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
{Sites.count_organization_sites(@organization.id)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Geocoded Sites</div>
|
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
{length(@sites)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Coverage</div>
|
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
<%= if Sites.count_organization_sites(@organization.id) > 0 do %>
|
|
{round(length(@sites) / Sites.count_organization_sites(@organization.id) * 100)}%
|
|
<% else %>
|
|
0%
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sites with Coordinates -->
|
|
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
|
{t("Sites with Geographic Data")}
|
|
</h3>
|
|
</div>
|
|
<div class="max-h-96 overflow-y-auto">
|
|
<%= if length(@sites) > 0 do %>
|
|
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for site <- @sites do %>
|
|
<div class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center">
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
{site.name}
|
|
</h4>
|
|
<%= if site.device_count > 0 do %>
|
|
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
|
{site.device_count} devices
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
<%= if site.address do %>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{site.address}
|
|
</p>
|
|
<% end %>
|
|
<%= if site.description do %>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{site.description}
|
|
</p>
|
|
<% end %>
|
|
<p class="mt-1 text-xs text-gray-400">
|
|
{Float.round(site.latitude, 4)}, {Float.round(site.longitude, 4)}
|
|
</p>
|
|
</div>
|
|
<div class="ml-4">
|
|
<.link
|
|
navigate={~p"/sites/#{site.id}"}
|
|
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
|
|
>
|
|
View →
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="px-6 py-8 text-center">
|
|
<.icon name="hero-map-pin" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
{t("No geocoded sites")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Add addresses and coordinates to your sites to see them on the map.")}
|
|
</p>
|
|
<div class="mt-4">
|
|
<.button navigate={~p"/sites"}>
|
|
{t("Manage Sites")}
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layouts.authenticated>
|