feat: add global pricing defaults UI to admin orgs page
This commit is contained in:
parent
4f102f4acc
commit
a5e26745ae
3 changed files with 389 additions and 4 deletions
|
|
@ -4,12 +4,18 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Admin
|
||||
alias Towerops.Billing
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
orgs = Admin.list_all_organizations()
|
||||
default_free_devices = Billing.default_free_devices()
|
||||
default_price = Billing.default_price_per_device()
|
||||
active_sub_count = count_active_subscriptions()
|
||||
|
||||
ip =
|
||||
case get_connect_info(socket, :peer_data) do
|
||||
|
|
@ -24,17 +30,41 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
|> assign(:organizations, orgs)
|
||||
|> assign(:client_ip, ip)
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
|> assign(:override_form, nil)
|
||||
|> assign(:default_free_devices, default_free_devices)
|
||||
|> assign(:default_price, default_price)
|
||||
|> assign(:active_sub_count, active_sub_count)
|
||||
|> assign(:editing_global, false)
|
||||
|> assign(:global_form, nil)
|
||||
|> assign(:show_confirm, false)
|
||||
|> assign(:pending_pricing_params, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"edit_global" => "true"} = _params, _url, socket) do
|
||||
form =
|
||||
to_form(%{
|
||||
"default_free_devices" => socket.assigns.default_free_devices,
|
||||
"default_price_per_device" => Decimal.to_string(socket.assigns.default_price)
|
||||
})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_global, true)
|
||||
|> assign(:global_form, form)
|
||||
|> assign(:show_confirm, false)
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
end
|
||||
|
||||
def handle_params(params, _url, socket) do
|
||||
case params["edit"] do
|
||||
nil ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
|> assign(:override_form, nil)
|
||||
|> assign(:editing_global, false)}
|
||||
|
||||
org_id ->
|
||||
org = Enum.find(socket.assigns.organizations, &(&1.id == org_id))
|
||||
|
|
@ -45,12 +75,14 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_org, org)
|
||||
|> assign(:override_form, to_form(changeset))}
|
||||
|> assign(:override_form, to_form(changeset))
|
||||
|> assign(:editing_global, false)}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
|> assign(:override_form, nil)
|
||||
|> assign(:editing_global, false)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -128,4 +160,107 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
{:noreply, put_flash(socket, :error, t_admin("Failed to delete organization"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("edit_global_defaults", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/organizations?edit_global=true")}
|
||||
end
|
||||
|
||||
def handle_event("validate_global_pricing", %{"global_pricing" => params}, socket) do
|
||||
errors = validate_global_pricing_params(params)
|
||||
form = to_form(params, errors: errors)
|
||||
|
||||
{:noreply, assign(socket, :global_form, form)}
|
||||
end
|
||||
|
||||
def handle_event("save_global_pricing", %{"global_pricing" => params}, socket) do
|
||||
case validate_global_pricing_params(params) do
|
||||
[] ->
|
||||
# Valid - show confirmation
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_confirm, true)
|
||||
|> assign(:pending_pricing_params, params)}
|
||||
|
||||
errors ->
|
||||
form = to_form(params, errors: errors)
|
||||
{:noreply, assign(socket, :global_form, form)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("confirm_pricing_update", _params, socket) do
|
||||
params = socket.assigns.pending_pricing_params
|
||||
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
|
||||
|
||||
case Admin.update_global_pricing(params, superuser.id, socket.assigns.client_ip) do
|
||||
{:ok, result} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"Pricing updated successfully. #{result.succeeded}/#{result.total} subscriptions migrated."
|
||||
)
|
||||
|> push_patch(to: ~p"/admin/organizations")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to update pricing: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_global_edit", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/organizations")}
|
||||
end
|
||||
|
||||
defp validate_global_pricing_params(params) do
|
||||
errors = []
|
||||
|
||||
errors =
|
||||
case Map.get(params, "default_free_devices") do
|
||||
nil ->
|
||||
errors
|
||||
|
||||
value ->
|
||||
case Integer.parse(value) do
|
||||
{int, _} when int > 0 and int < 10_000 ->
|
||||
errors
|
||||
|
||||
_ ->
|
||||
[
|
||||
{:default_free_devices, {"must be greater than 0 and less than 10,000", []}}
|
||||
| errors
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
errors =
|
||||
case Map.get(params, "default_price_per_device") do
|
||||
nil ->
|
||||
errors
|
||||
|
||||
value ->
|
||||
case Decimal.parse(value) do
|
||||
{decimal, _} ->
|
||||
if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
|
||||
Decimal.compare(decimal, "999.99") in [:lt, :eq] do
|
||||
errors
|
||||
else
|
||||
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
|
||||
end
|
||||
|
||||
:error ->
|
||||
[{:default_price_per_device, {"must be a valid decimal", []}} | errors]
|
||||
end
|
||||
end
|
||||
|
||||
errors
|
||||
end
|
||||
|
||||
defp count_active_subscriptions do
|
||||
alias Towerops.Repo
|
||||
|
||||
Repo.one(
|
||||
from o in Organization,
|
||||
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
|
||||
select: count(o.id)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,54 @@
|
|||
<p class="text-gray-600 dark:text-gray-400">{t_admin("Manage organizations")}</p>
|
||||
</div>
|
||||
|
||||
<!-- Global Billing Defaults Card -->
|
||||
<div
|
||||
class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800"
|
||||
data-test="global-defaults-card"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
|
||||
Global Billing Defaults
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Default pricing for all organizations (can be overridden per-org)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="edit_global_defaults"
|
||||
data-test="edit-global-defaults"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Edit Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">Free Devices</dt>
|
||||
<dd
|
||||
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
|
||||
data-test="default-free-devices"
|
||||
>
|
||||
{@default_free_devices}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
Price Per Device
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
|
||||
data-test="default-price"
|
||||
>
|
||||
${@default_price}/mo
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<.table id="organizations" rows={@organizations}>
|
||||
<:col :let={org} label={t("Name")}>{org.name}</:col>
|
||||
|
|
@ -130,5 +178,128 @@
|
|||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Edit Global Defaults Modal --%>
|
||||
<%= if @editing_global do %>
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
data-test="global-defaults-modal"
|
||||
>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div
|
||||
class="fixed inset-0 bg-slate-500 bg-opacity-75"
|
||||
phx-click="cancel_global_edit"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
|
||||
Edit Global Billing Defaults
|
||||
</h3>
|
||||
|
||||
<.form
|
||||
for={@global_form}
|
||||
phx-change="validate_global_pricing"
|
||||
phx-submit="save_global_pricing"
|
||||
data-test="global-defaults-form"
|
||||
class="mt-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Free Devices
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="global_pricing[default_free_devices]"
|
||||
value={@global_form.data["default_free_devices"]}
|
||||
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
<%= if error = @global_form.errors[:default_free_devices] do %>
|
||||
<p
|
||||
id="global_pricing_default_free_devices-error"
|
||||
class="mt-1 text-sm text-red-600"
|
||||
>
|
||||
{elem(error, 0)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Price Per Device (USD/month)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="global_pricing[default_price_per_device]"
|
||||
value={@global_form.data["default_price_per_device"]}
|
||||
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
<%= if error = @global_form.errors[:default_price_per_device] do %>
|
||||
<p
|
||||
id="global_pricing_default_price_per_device-error"
|
||||
class="mt-1 text-sm text-red-600"
|
||||
>
|
||||
{elem(error, 0)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_global_edit"
|
||||
data-test="cancel-global-edit"
|
||||
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<%!-- Confirmation Dialog --%>
|
||||
<%= if @show_confirm do %>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-slate-900/50"
|
||||
data-test="confirm-pricing-change"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||
<h4 class="text-lg font-medium text-slate-900 dark:text-white">
|
||||
Confirm Price Change
|
||||
</h4>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
This will update {@active_sub_count} active subscriptions to the new price.
|
||||
Changes take effect at the next billing cycle.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_global_edit"
|
||||
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="confirm_pricing_update"
|
||||
data-test="confirm-pricing-update"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Confirm Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.admin>
|
||||
|
|
|
|||
|
|
@ -165,4 +165,83 @@ defmodule ToweropsWeb.Admin.OrgLive.IndexTest do
|
|||
refute has_element?(view, "#billing-override-form")
|
||||
end
|
||||
end
|
||||
|
||||
describe "global pricing defaults" do
|
||||
test "renders global defaults card", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
|
||||
|
||||
assert has_element?(view, "[data-test='global-defaults-card']")
|
||||
assert has_element?(view, "[data-test='default-free-devices']", "10")
|
||||
assert has_element?(view, "[data-test='default-price']")
|
||||
end
|
||||
|
||||
test "opens edit modal when clicking edit button", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
|
||||
|
||||
view
|
||||
|> element("[data-test='edit-global-defaults']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "[data-test='global-defaults-modal']")
|
||||
assert has_element?(view, "input[name='global_pricing[default_free_devices]']")
|
||||
assert has_element?(view, "input[name='global_pricing[default_price_per_device]']")
|
||||
end
|
||||
|
||||
test "validates pricing values", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
|
||||
|
||||
view
|
||||
|> form("[data-test='global-defaults-form']", %{
|
||||
global_pricing: %{
|
||||
default_free_devices: "-5",
|
||||
default_price_per_device: "1000.00"
|
||||
}
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
assert has_element?(view, "#global_pricing_default_free_devices-error")
|
||||
assert has_element?(view, "#global_pricing_default_price_per_device-error")
|
||||
end
|
||||
|
||||
test "shows confirmation dialog before saving", %{conn: conn, user: user} do
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
_org1 =
|
||||
organization_fixture(user.id, %{
|
||||
subscription_status: "active",
|
||||
stripe_subscription_id: "sub_1"
|
||||
})
|
||||
|
||||
_org2 =
|
||||
organization_fixture(user.id, %{
|
||||
subscription_status: "active",
|
||||
stripe_subscription_id: "sub_2"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
|
||||
|
||||
view
|
||||
|> form("[data-test='global-defaults-form']", %{
|
||||
global_pricing: %{
|
||||
default_free_devices: "15",
|
||||
default_price_per_device: "3.00"
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
# Should show confirmation dialog
|
||||
assert has_element?(view, "[data-test='confirm-pricing-change']")
|
||||
end
|
||||
|
||||
test "cancels edit without changes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
|
||||
|
||||
view
|
||||
|> element("[data-test='cancel-global-edit']")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "[data-test='global-defaults-modal']")
|
||||
assert_patched(view, ~p"/admin/organizations")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue