# User Detail Tabs: Principles and Developer Guide

This guide explains how to add a new tab to the Admin User Detail page in a safe, maintainable way.

It is intended for developers working across umbrella apps, including teams that own domain modules outside `wallet_web`.

See also:

- `docs/umbrella-ui-injection-architecture-guide.md` for the umbrella-wide architecture policy used by AI agents and developers.
- `docs/adr/0015-ui-composition-and-extension-architecture.md` for the formal architecture decision record.

## 1. Architecture principles

### 1.1 Keep dependency direction correct

Use this dependency flow:

- `wallet_web` (UI layer) depends on domain apps (`wallet_transfers`, `wallet_loans`, etc.)
- Domain apps should not depend on `wallet_web` unless they intentionally provide optional UI plugin tabs

Avoid cyclic dependencies between umbrella apps.

### 1.2 UI code belongs to the UI boundary

A tab is a Phoenix LiveComponent (rendering, HEEx, CSS, events). That is UI concern.

- If a tab is a core admin feature, keep it in `wallet_web`
- If a tab is optional and owned by another team/app, that app can implement the tab and register it with the registry

### 1.3 Domain logic stays in domain apps

Tab components should call domain query functions or services. Keep business rules in domain modules.

## 2. Current extension points

Main modules involved:

- Contract: `apps/wallet_web/lib/wallet_web/live/admin/user_detail/tab_behaviour.ex`
- Registry: `apps/wallet_web/lib/wallet_web/live/admin/user_detail/tab_registry.ex`
- Parent coordinator: `apps/wallet_web/lib/wallet_web/live/admin/user_detail_live.ex`
- Registry startup: `apps/wallet_web/lib/wallet_web/application.ex`

The registry is started under `WalletWeb.Application` and merges:

- core tabs (shipped by `wallet_web`)
- extension tabs from config and/or runtime registration

## 3. Ownership decision: where should your tab live?

Use this rule:

- Put tab in `wallet_web` when:
  - the tab is required for all environments
  - the admin UI team owns and maintains it
- Put tab in another app when:
  - the feature is optional or team-owned outside `wallet_web`
  - the app can depend on `wallet_web` for UI integration

## 4. Implementation checklist

1. Create a tab module that:
   - uses `WalletWeb, :live_component`
   - implements `WalletWeb.Live.Admin.UserDetail.TabBehaviour`
2. Implement `tab_spec/0` with a unique `:id` atom and sort `:order`
3. Implement `update/2` and always merge incoming assigns (`assign(socket, assigns)`)
4. Implement `render/1`
5. For interactive elements, use `phx-target={@myself}`
6. Use tab->parent messaging for global flash/error/reload interactions
7. Register the tab (config or runtime)
8. Validate route behavior with `?tab=<id>` and browser back/forward

## 5. Required module contract

Every tab must implement:

```elixir
@behaviour WalletWeb.Live.Admin.UserDetail.TabBehaviour

@impl WalletWeb.Live.Admin.UserDetail.TabBehaviour
def tab_spec do
  %{
    id: :my_tab,
    label: "My Tab",
    icon: "hero-star",
    order: 50
  }
end
```

Notes:

- `id` must be a unique atom across all tabs
- `order` controls nav ordering (ascending)
- `icon` should be a valid hero icon name used in existing UI

## 6. Starter template for a new tab

```elixir
defmodule WalletLoans.AdminTabs.LoansTab do
  use WalletWeb, :live_component

  @behaviour WalletWeb.Live.Admin.UserDetail.TabBehaviour

  alias WalletLoans.Queries.ListUserLoans

  @impl WalletWeb.Live.Admin.UserDetail.TabBehaviour
  def tab_spec do
    %{id: :loans, label: "Loans", icon: "hero-banknotes", order: 30}
  end

  @impl true
  def update(assigns, socket) do
    # Important: keep :id and any incoming assigns available to render/1.
    socket = assign(socket, assigns)

    user = socket.assigns[:user]

    if user && user.user_id != socket.assigns[:current_user_id] do
      loans = ListUserLoans.call(user.user_id)

      {:ok,
       socket
       |> assign(:current_user_id, user.user_id)
       |> assign(:loans, loans)}
    else
      {:ok, socket}
    end
  end

  @impl true
  def handle_event("refresh", _params, socket) do
    user_id = socket.assigns.user.user_id
    loans = ListUserLoans.call(user_id)

    {:noreply, assign(socket, :loans, loans)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="rounded-xl border border-zinc-200 bg-white shadow-sm p-6">
      <div class="mb-4 flex items-center justify-between">
        <h2 class="text-sm font-semibold text-zinc-700">Loans</h2>
        <button
          phx-click="refresh"
          phx-target={@myself}
          class="rounded-lg border border-zinc-300 px-3 py-1.5 text-xs"
        >
          Refresh
        </button>
      </div>

      <%= if Enum.empty?(@loans || []) do %>
        <p class="text-sm text-zinc-500">No loans found.</p>
      <% else %>
        <ul class="space-y-2 text-sm">
          <%= for loan <- @loans do %>
            <li class="rounded border border-zinc-100 p-3">
              <span class="font-medium"><%= loan.loan_id %></span>
              <span class="ml-2 text-zinc-500"><%= loan.status %></span>
            </li>
          <% end %>
        </ul>
      <% end %>
    </div>
    """
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end
```

## 7. Registration options

### 7.1 Config-based registration (recommended for stable tabs)

Add to config:

```elixir
config :wallet_web, :user_detail_extension_tabs, [
  WalletLoans.AdminTabs.LoansTab
]
```

### 7.2 Runtime registration (useful for dynamic startup)

Call in your app start:

```elixir
def start(_type, _args) do
  WalletWeb.Live.Admin.UserDetail.TabRegistry.register_tab(WalletLoans.AdminTabs.LoansTab)
  Supervisor.start_link(children, strategy: :one_for_one, name: WalletLoans.Supervisor)
end
```

## 8. Parent-child communication contract

### 8.1 Tab -> parent

Send messages from tab to `UserDetailLive` process:

```elixir
send(self(), {__MODULE__, {:ok, "Loan approved."}})
send(self(), {__MODULE__, {:error, "Approval failed."}})
send(self(), {__MODULE__, :reload_user})
```

Handled by parent in `handle_info/2`.

### 8.2 Parent -> tab

Parent uses `send_update/3` when user is reloaded:

```elixir
Phoenix.LiveView.send_update(self(), tab_module,
  id: "user-detail-tab-<tab_id>",
  user: updated_user
)
```

## 9. Event handling and UI safety rules

- Always use `phx-target={@myself}` for tab-owned events
- Do not rely on parent `handle_event/3` for tab-internal behavior
- Namespace modal IDs using tab component id when needed:
  - Example: `"txn-modal-#{@id}"`
- Use conservative defaults when optional assigns are absent

## 10. Common pitfalls and fixes

### 10.1 KeyError: key :id not found in assigns

Cause:

- Custom `update/2` did not merge incoming assigns

Fix:

```elixir
socket = assign(socket, assigns)
```

Do this at the beginning of `update/2`.

### 10.2 Unknown tab from URL query

`UserDetailLive` parses `?tab=<id>` and falls back safely to `:overview` when unknown.

Tip:

- Ensure your `tab_spec().id` is an atom and registration happens before use

### 10.3 Registry not running

If registration is attempted before registry startup, runtime registration returns `{:error, :no_registry}`.

Tip:

- Ensure `WalletWeb.Live.Admin.UserDetail.TabRegistry` is in `WalletWeb.Application` children

## 11. Suggested file locations

For core tabs in `wallet_web`:

- `apps/wallet_web/lib/wallet_web/live/admin/user_detail/tabs/<feature>_tab.ex`

For extension tabs in another umbrella app:

- `apps/<your_app>/lib/<your_app>/admin_tabs/<feature>_tab.ex`

## 12. Review checklist before merge

- Tab renders correctly for target users
- Browser back/forward works with `?tab=<id>`
- No LiveComponent KeyError for `:id` or missing assigns
- Events are scoped with `phx-target={@myself}`
- Parent flash/error messaging works
- No cross-app circular dependency introduced
- Tests cover data loading and at least one interaction path
