# Phase Recon-4 — LiveView Recon Wizard UI

**Status:** ⬜ Pending
**Duration:** Weeks 7–8
**Depends on:** Phase Recon-3
**Goal:** Non-technical user can upload files, run reconciliation, and download a report entirely through the admin UI. No terminal, no scripts.

---

## New Routes (`gateway_web/router.ex`)

Add inside the `:admin` live_session block:

```elixir
live "/recon",           ReconWizardLive,  :index
live "/recon/history",   ReconHistoryLive, :index
live "/recon/config",    ReconConfigLive,  :index   # Phase 5
```

---

## Sidebar Navigation

Add to `apps/gateway_web/priv/layouts/app.html.heex` (or the nav component):

```html
<.nav_item icon="hero-arrow-path" path={~p"/admin/recon"} label="Reconciliation"
           active={@active_nav == "recon"} />
```

---

## `ReconWizardLive` — 4-Step Wizard

### State machine

```
:step_1   User selects hospital (tenant) + recon type + date
    │  "next" event (validate: hospital and type selected)
    ▼
:step_2   File upload slots shown based on recon type
    │  "run" event (validate: all required files uploaded)
    ▼
:step_3   Job in progress — live progress bar via PubSub
    │  PubSub :completed or :failed
    ▼
:step_4   Results summary + Download button
```

### `recon_wizard_live.ex`

```elixir
defmodule GatewayWebWeb.ReconWizardLive do
  use GatewayWebWeb, :live_view

  alias InfraRepo.{ReconSessions, ReconConfigs}
  alias MwRecon.Orchestrator

  @steps [:step_1, :step_2, :step_3, :step_4]

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket,
      page_title:   "Reconciliation",
      active_nav:   "recon",
      step:         :step_1,
      # Step 1
      hospitals:    load_hospitals(socket),
      recon_types:  [],
      tenant_id:    nil,
      config_id:    nil,
      recon_type:   nil,
      recon_date:   Date.utc_today() |> Date.to_iso8601(),
      step1_errors: [],
      # Step 2
      file_slots:   [],
      uploads:      %{},   # role => %{filename, content_b64, size_kb}
      session_id:   nil,
      step2_errors: [],
      # Step 3
      job_status:   :idle,
      job_progress: "Waiting...",
      job_id:       nil,
      # Step 4
      result:       nil,
      error_msg:    nil
    )}
  end

  # ── Step 1 events ──────────────────────────────────────────────────────────

  @impl true
  def handle_event("select_hospital", %{"tenant_id" => tenant_id}, socket) do
    configs    = ReconConfigs.list_enabled_for_tenant(String.to_integer(tenant_id))
    recon_types = Enum.map(configs, &{&1.display_name, &1.recon_type, &1.id})
    {:noreply, assign(socket, tenant_id: String.to_integer(tenant_id), recon_types: recon_types,
                               recon_type: nil, config_id: nil)}
  end

  def handle_event("select_recon_type", %{"recon_type" => rt, "config_id" => cid}, socket) do
    {:noreply, assign(socket, recon_type: rt, config_id: String.to_integer(cid))}
  end

  def handle_event("update_date", %{"date" => date}, socket) do
    {:noreply, assign(socket, recon_date: date)}
  end

  def handle_event("step1_next", _params, socket) do
    errors = validate_step1(socket.assigns)
    if errors == [] do
      # Create session in DB
      {:ok, session} = ReconSessions.create_session(%{
        tenant_id:   socket.assigns.tenant_id,
        config_id:   socket.assigns.config_id,
        recon_type:  socket.assigns.recon_type,
        recon_date:  Date.from_iso8601!(socket.assigns.recon_date),
        status:      "pending"
      })
      slots = file_slots_for(socket.assigns.recon_type)
      {:noreply, assign(socket, step: :step_2, session_id: session.id,
                                file_slots: slots, step1_errors: [])}
    else
      {:noreply, assign(socket, step1_errors: errors)}
    end
  end

  # ── Step 2 events ──────────────────────────────────────────────────────────

  def handle_event("file_uploaded", %{"role" => role, "filename" => fn_, "content" => b64}, socket) do
    size_kb = round(byte_size(b64) * 3 / 4 / 1024)
    uploads = Map.put(socket.assigns.uploads, role, %{filename: fn_, content_b64: b64, size_kb: size_kb})
    {:noreply, assign(socket, uploads: uploads, step2_errors: [])}
  end

  def handle_event("remove_file", %{"role" => role}, socket) do
    {:noreply, assign(socket, uploads: Map.delete(socket.assigns.uploads, role))}
  end

  def handle_event("step2_run", _params, socket) do
    required = Enum.map(socket.assigns.file_slots, & &1.role)
    missing  = required -- Map.keys(socket.assigns.uploads)
    if missing != [] do
      {:noreply, assign(socket, step2_errors: ["Missing files: #{Enum.join(missing, ", ")}"])}
    else
      # Upload all files and start the job
      socket = assign(socket, step: :step_3, job_status: :running,
                               job_progress: "Uploading files...")
      send(self(), {:start_recon_job})
      {:noreply, socket}
    end
  end

  # ── Step 3: start job + subscribe to PubSub ────────────────────────────────

  @impl true
  def handle_info({:start_recon_job}, socket) do
    session_id = socket.assigns.session_id
    Phoenix.PubSub.subscribe(MwCore.PubSub, "recon:session:#{session_id}")

    # Upload each file via API (or directly via Elixir context)
    Enum.each(socket.assigns.uploads, fn {role, %{filename: fn_, content_b64: b64}} ->
      InfraRepo.ReconSessions.add_run_file(%{
        session_id:  session_id,
        role:        role,
        filename:    fn_,
        file_format: detect_format(fn_),
        content:     b64
      })
    end)

    # Start the async job
    session = InfraRepo.ReconSessions.get_session_with_files(session_id)
    MwRecon.Orchestrator.start_run(session)

    {:noreply, assign(socket, job_progress: "Parsing files...")}
  end

  def handle_info({:parsing, _}, socket),
    do: {:noreply, assign(socket, job_progress: "Parsing uploaded files...")}

  def handle_info({:matching, _}, socket),
    do: {:noreply, assign(socket, job_progress: "Running reconciliation...")}

  def handle_info({:reporting, _}, socket),
    do: {:noreply, assign(socket, job_progress: "Generating report...")}

  def handle_info({:completed, summary}, socket) do
    session = InfraRepo.ReconSessions.get_session!(socket.assigns.session_id)
    {:noreply, assign(socket,
      step:       :step_4,
      job_status: :done,
      result:     %{
        session:         session,
        summary:         summary,
        matched_count:   session.matched_count,
        unmatched_count: session.unmatched_count,
        total_count:     session.total_count
      }
    )}
  end

  def handle_info({:failed, %{error: reason}}, socket) do
    {:noreply, assign(socket,
      step:      :step_4,
      job_status: :error,
      error_msg:  reason
    )}
  end

  # ── Step 4: download trigger ───────────────────────────────────────────────

  def handle_event("download_report", _params, socket) do
    # Push a JS redirect to the download endpoint
    {:noreply, push_navigate(socket,
      to: "/api/v1/recon/sessions/#{socket.assigns.session_id}/report"
    )}
  end

  def handle_event("start_new", _params, socket) do
    {:noreply, assign(socket,
      step: :step_1, tenant_id: nil, config_id: nil, recon_type: nil,
      session_id: nil, uploads: %{}, result: nil, error_msg: nil,
      job_status: :idle, job_progress: "Waiting..."
    )}
  end

  # ── Helpers ────────────────────────────────────────────────────────────────

  defp file_slots_for(recon_type) do
    case recon_type do
      "bank_card_vs_momentspay" -> [
        %{role: "bank_card",  label: "Bank Card Statement",  format: "XLSX", hint: "From bank portal — Card transactions"},
        %{role: "momentspay", label: "MomentsPay Export",    format: "CSV",  hint: "From MomentsPay dashboard"}
      ]
      "bank_upi_vs_momentspay" -> [
        %{role: "bank_upi",   label: "Bank UPI Statement",   format: "XLSX", hint: "From bank portal — UPI/QR transactions"},
        %{role: "momentspay", label: "MomentsPay Export",    format: "CSV",  hint: "From MomentsPay dashboard"}
      ]
      "his_bank_card" -> [
        %{role: "his",        label: "HIS Export",           format: "XLSX", hint: "Hospital Information System — Daily transactions"},
        %{role: "bank_card",  label: "Bank Card Statement",  format: "XLSX", hint: "From bank portal — Card transactions"}
      ]
      "his_bank_upi" -> [
        %{role: "his",        label: "HIS Export",           format: "XLSX", hint: "Hospital Information System — Daily transactions"},
        %{role: "bank_upi",   label: "Bank UPI Statement",   format: "XLSX", hint: "From bank portal — UPI/QR transactions"}
      ]
      "his_momentspay_card" -> [
        %{role: "his",        label: "HIS Export",           format: "XLSX", hint: "Hospital Information System — Daily transactions"},
        %{role: "momentspay", label: "MomentsPay Export",    format: "CSV",  hint: "From MomentsPay dashboard"},
        %{role: "bank_card",  label: "Bank Card Statement",  format: "XLSX", hint: "From bank portal — Card transactions"}
      ]
      "his_momentspay_upi" -> [
        %{role: "his",        label: "HIS Export",           format: "XLSX", hint: "Hospital Information System — Daily transactions"},
        %{role: "momentspay", label: "MomentsPay Export",    format: "CSV",  hint: "From MomentsPay dashboard"},
        %{role: "bank_upi",   label: "Bank UPI Statement",   format: "XLSX", hint: "From bank portal — UPI/QR transactions"}
      ]
      "amex" -> [
        %{role: "his",  label: "HIS Export",      format: "XLSX", hint: "Hospital Information System — Daily transactions"},
        %{role: "amex", label: "AMEX Statement",  format: "CSV",  hint: "AMEX merchant statement"}
      ]
      _ -> []
    end
  end

  defp validate_step1(%{tenant_id: nil}),  do: ["Please select a hospital"]
  defp validate_step1(%{recon_type: nil}), do: ["Please select a reconciliation type"]
  defp validate_step1(%{recon_date: ""}),  do: ["Please select a reconciliation date"]
  defp validate_step1(_),                  do: []

  defp load_hospitals(_socket) do
    InfraRepo.Repo.all(InfraRepo.Schemas.Tenant)
    |> Enum.map(&{&1.name, &1.id})
  end

  defp detect_format(filename) do
    filename |> Path.extname() |> String.trim_leading(".") |> String.downcase()
  end
end
```

---

## `recon_wizard_live.html.heex` — UI Layout

```html
<div class="max-w-3xl mx-auto py-8 px-4">
  <!-- Step indicator -->
  <div class="flex items-center mb-8">
    <%= for {label, step_key, idx} <- [{"Select Type", :step_1, 1}, {"Upload Files", :step_2, 2},
                                        {"Reconciling", :step_3, 3}, {"Results", :step_4, 4}] do %>
      <div class={"flex items-center #{if @step == step_key, do: "text-blue-600 font-semibold", else: "text-gray-400"}"}>
        <span class={"w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
                       #{if step_reached?(@step, step_key), do: "bg-blue-600 text-white", else: "bg-gray-200 text-gray-500"}"}>
          <%= idx %>
        </span>
        <span class="ml-2 hidden sm:block"><%= label %></span>
      </div>
      <%= if idx < 4 do %>
        <div class="flex-1 h-0.5 mx-3 bg-gray-200"></div>
      <% end %>
    <% end %>
  </div>

  <!-- Step 1: Select hospital + type -->
  <%= if @step == :step_1 do %>
    <div class="bg-white rounded-xl shadow-sm border p-6">
      <h2 class="text-lg font-semibold mb-4">Select Hospital &amp; Reconciliation Type</h2>
      <!-- Hospital dropdown -->
      <label class="block text-sm font-medium text-gray-700 mb-1">Hospital</label>
      <select phx-change="select_hospital" name="tenant_id" class="w-full border rounded-lg px-3 py-2 mb-4">
        <option value="">-- Select Hospital --</option>
        <%= for {name, id} <- @hospitals do %>
          <option value={id} selected={@tenant_id == id}><%= name %></option>
        <% end %>
      </select>
      <!-- Recon type dropdown (shown after hospital selected) -->
      <%= if @recon_types != [] do %>
        <label class="block text-sm font-medium text-gray-700 mb-1">Reconciliation Type</label>
        <select phx-change="select_recon_type" name="recon_type" class="w-full border rounded-lg px-3 py-2 mb-4">
          <option value="">-- Select Type --</option>
          <%= for {display, rt, cid} <- @recon_types do %>
            <option value={rt} data-config-id={cid} selected={@recon_type == rt}><%= display %></option>
          <% end %>
        </select>
        <!-- Date -->
        <label class="block text-sm font-medium text-gray-700 mb-1">Reconciliation Date</label>
        <input type="date" phx-change="update_date" name="date" value={@recon_date}
               class="w-full border rounded-lg px-3 py-2 mb-4" />
      <% end %>
      <!-- Errors -->
      <%= for err <- @step1_errors do %>
        <p class="text-red-500 text-sm mb-2"><%= err %></p>
      <% end %>
      <button phx-click="step1_next"
              class="w-full bg-blue-600 text-white py-2 rounded-lg font-medium hover:bg-blue-700">
        Next →
      </button>
    </div>
  <% end %>

  <!-- Step 2: File uploads -->
  <%= if @step == :step_2 do %>
    <div class="bg-white rounded-xl shadow-sm border p-6">
      <h2 class="text-lg font-semibold mb-4">Upload Required Files</h2>
      <%= for slot <- @file_slots do %>
        <div class="border rounded-lg p-4 mb-3">
          <div class="flex items-center justify-between mb-1">
            <span class="font-medium text-sm"><%= slot.label %></span>
            <span class="text-xs text-gray-400"><%= slot.format %></span>
          </div>
          <p class="text-xs text-gray-500 mb-2"><%= slot.hint %></p>
          <%= if uploaded = Map.get(@uploads, slot.role) do %>
            <div class="flex items-center bg-green-50 border border-green-200 rounded px-3 py-2">
              <span class="text-green-700 text-sm flex-1">✓ <%= uploaded.filename %> (<%= uploaded.size_kb %> KB)</span>
              <button phx-click="remove_file" phx-value-role={slot.role} class="text-gray-400 hover:text-red-500 ml-2">✕</button>
            </div>
          <% else %>
            <label class="block cursor-pointer">
              <div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors">
                <span class="text-sm text-gray-500">Click to upload <%= slot.format %></span>
              </div>
              <input type="file" class="hidden"
                     accept={if slot.format == "CSV", do: ".csv", else: ".xlsx,.xls"}
                     phx-hook="FileUpload" data-role={slot.role} />
            </label>
          <% end %>
        </div>
      <% end %>
      <%= for err <- @step2_errors do %>
        <p class="text-red-500 text-sm mb-2"><%= err %></p>
      <% end %>
      <div class="flex gap-3 mt-4">
        <button phx-click="step1_back" class="flex-1 border py-2 rounded-lg text-gray-600 hover:bg-gray-50">← Back</button>
        <button phx-click="step2_run" class="flex-1 bg-blue-600 text-white py-2 rounded-lg font-medium hover:bg-blue-700">
          Run Reconciliation
        </button>
      </div>
    </div>
  <% end %>

  <!-- Step 3: In progress -->
  <%= if @step == :step_3 do %>
    <div class="bg-white rounded-xl shadow-sm border p-8 text-center">
      <div class="animate-spin w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
      <h2 class="text-lg font-semibold mb-2">Reconciliation in Progress</h2>
      <p class="text-gray-500"><%= @job_progress %></p>
    </div>
  <% end %>

  <!-- Step 4: Results -->
  <%= if @step == :step_4 do %>
    <%= if @job_status == :done do %>
      <div class="bg-white rounded-xl shadow-sm border p-6">
        <div class="text-center mb-6">
          <div class="text-4xl mb-2">✅</div>
          <h2 class="text-lg font-semibold">Reconciliation Complete</h2>
        </div>
        <div class="grid grid-cols-3 gap-4 mb-6">
          <div class="bg-gray-50 rounded-lg p-4 text-center">
            <div class="text-2xl font-bold text-gray-700"><%= @result.total_count || 0 %></div>
            <div class="text-xs text-gray-500 mt-1">Total Transactions</div>
          </div>
          <div class="bg-green-50 rounded-lg p-4 text-center">
            <div class="text-2xl font-bold text-green-600"><%= @result.matched_count || 0 %></div>
            <div class="text-xs text-gray-500 mt-1">Matched</div>
          </div>
          <div class="bg-red-50 rounded-lg p-4 text-center">
            <div class="text-2xl font-bold text-red-500"><%= @result.unmatched_count || 0 %></div>
            <div class="text-xs text-gray-500 mt-1">Unmatched</div>
          </div>
        </div>
        <div class="flex gap-3">
          <button phx-click="download_report"
                  class="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700">
            ⬇ Download Excel Report
          </button>
          <button phx-click="start_new"
                  class="flex-1 border py-3 rounded-lg text-gray-600 hover:bg-gray-50">
            New Reconciliation
          </button>
        </div>
      </div>
    <% else %>
      <div class="bg-white rounded-xl shadow-sm border p-6 text-center">
        <div class="text-4xl mb-3">❌</div>
        <h2 class="text-lg font-semibold mb-2">Reconciliation Failed</h2>
        <p class="text-red-500 text-sm mb-4"><%= @error_msg %></p>
        <button phx-click="start_new" class="bg-blue-600 text-white px-6 py-2 rounded-lg">Try Again</button>
      </div>
    <% end %>
  <% end %>
</div>
```

---

## FileUpload Hook (`assets/js/hooks/recon_file_upload.js`)

```javascript
// Reads the file as base64 and pushes it to LiveView via phx-hook.
// Avoids multipart form complexity — sends content inline as a pushEvent.
const ReconFileUpload = {
  mounted() {
    this.el.addEventListener('change', (event) => {
      const file = event.target.files[0];
      if (!file) return;
      const role = this.el.dataset.role;

      if (file.size > 20 * 1024 * 1024) {
        alert('File exceeds 20MB limit.');
        return;
      }

      const reader = new FileReader();
      reader.onload = (e) => {
        // Strip data URL prefix to get pure base64
        const base64 = e.target.result.split(',')[1];
        this.pushEvent('file_uploaded', {
          role:     role,
          filename: file.name,
          content:  base64
        });
      };
      reader.readAsDataURL(file);
    });
  }
};

export default ReconFileUpload;
```

Register in `app.js`:
```javascript
import ReconFileUpload from "./hooks/recon_file_upload";
let Hooks = { ..., ReconFileUpload };
```

---

## `ReconHistoryLive` (brief)

```elixir
defmodule GatewayWebWeb.ReconHistoryLive do
  use GatewayWebWeb, :live_view
  alias InfraRepo.ReconSessions

  def mount(_params, _session, socket) do
    tenant_id = socket.assigns.current_tenant_id
    sessions  = ReconSessions.list_sessions_for_tenant(tenant_id, limit: 100)
    {:ok, assign(socket, page_title: "Recon History", active_nav: "recon", sessions: sessions)}
  end
end
```

Template shows a table: Date | Hospital | Type | Total | Matched | Unmatched | Status | Download link.

---

## Acceptance Criteria

- [ ] User can complete a full reconciliation run without opening any documentation
- [ ] File upload slots change dynamically when recon type is selected
- [ ] Each file slot shows the expected format (XLSX/CSV) and a descriptive hint
- [ ] Step 3 spinner updates live: "Parsing files..." → "Running reconciliation..." → "Generating report..."
- [ ] Step 4 shows matched/unmatched counts prominently
- [ ] Download button returns the XLSX file immediately
- [ ] Error state shows a human-readable error message, not a stack trace
- [ ] History page shows all past sessions for the current hospital
- [ ] "Reconciliation" appears in the sidebar nav
