Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

Multi-Document Upload Implementation Plan (HTMX-Idiomatic)

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the current upload flow with a split-button upload that shows per-file classification progress in a popover, routes documents to the correct page, and animates new table rows — using HTMX-idiomatic patterns (HTML-over-the-wire, OOB swaps, no JSON).

Architecture: The upload form stays as an HTMX hx-post with hx-encoding="multipart/form-data". Instead of returning empty/redirect, the upload endpoint returns HTML that gets afterbegin-swapped into the popover body (batch separator + file items). SSE continues using a single new-document event type — the exec-fn returns one HTML payload containing both a popover-item OOB swap and a table row (when applicable). HTMX processes the OOB elements automatically. No SSE looper changes needed. Client-side JS is minimal — it manages popover visibility and derives state (badge count, summary) from DOM queries.

Key HTMX patterns used:

Tech Stack: Clojure (Hiccup, HoneySQL, core.async), HTMX + SSE extension + OOB swaps, vanilla JS, CSS animations

Design doc: docs/plans/2026-03-09-multi-document-upload-design.md Prototype: tmp-ui-sandbox/approach-A/button-indicator-v3.html


Task 1: CSS — row animation + upload UI styles

Add all CSS for the split button, popover, file items, activity bar, type tags, and row animation. These are standalone styles with no backend dependencies.

Files:

Step 1: Add row-slide-in keyframes

Insert near the existing .highlighted / highlight-fade block (~line 849):

/* New row entrance animation */
@keyframes row-slide-in {
  from { opacity: 0; transform: translateY(-8px); }
  to   { opacity: 1; transform: translateY(0); }
}
.row-new { animation: row-slide-in 0.3s ease-out; }

Step 2: Add split button, popover, file item, and activity bar styles

Insert after the .file-upload-label block (~line 1037). Copy the following CSS blocks from the prototype file (lines 168–477), adapting class names to match:

  1. .upload-wrapper — relative positioning for popover anchor
  2. .split-btn, .split-btn-upload, .split-btn-status, .status-count — split button layout (prototype lines 172–246)
  3. .upload-popover, .popover-header, .popover-body, .popover-summary, .pop-close, @keyframes popover-enter — popover structure (lines 248–314)
  4. .batch-separator — batch grouping divider (lines 316–335)
  5. .pop-file, .pop-file-icon (with .uploading, .classifying, .processing, .completed, .failed, .skipped), .pop-file-info, .pop-file-name, .pop-file-status — file items (lines 337–378)
  6. .pop-file-type with .type-invoice, .type-contract, .type-financial-notice, .type-purchase-order, .type-goods-received-note, .type-other — type tags (lines 380–402)
  7. .pop-activity, .pop-activity-bar, @keyframes activity-slide, .pop-activity.done — activity animation (lines 404–440)
  8. .popover-backdrop — click-outside overlay (lines 456–463)
  9. .batch-enter, @keyframes items-enter — popover item entrance animation (lines 472–477)

Copy the exact values from the prototype. The .split-btn-upload colors (#0078d4, hover #106ebe) already match the existing .file-upload-label.

Step 3: Verify in browser

Open any page, inspect the CSS file loads without errors. Visual verification comes in later tasks when the HTML is in place.

Step 4: Commit

git add resources/erp/public/css/style.css
git commit -m "feat: CSS for split upload button, popover, and row animation"

Task 2: upload.js — client-side popover state module

Minimal JS module that manages popover visibility and derives state from the DOM. No fetch(), no tracked file maps, no counters — the DOM is the source of truth.

Files:

Step 1: Create upload.js

/**
 * orchaUpload — client-side state for the upload split button and popover.
 *
 * Architecture: The DOM is the source of truth. Badge count, summary text,
 * and completion state are derived by querying popover-body children.
 * HTMX handles all data delivery (upload response, SSE updates).
 */
window.orchaUpload = (function () {
  var hideTimeout = null;

  // --- Public API ---

  /** Called via hx-on::after-request on the upload form. */
  function onUploadResponse(event) {
    if (!event.detail.successful) return;
    showStatus();
    updateBadge();
    updateSummary();
    openPopover();
  }

  /** Called via hx-on::load when HTMX swaps a popover item (SSE update). */
  function onItemLoad() {
    updateBadge();
    updateSummary();
  }

  function openPopover() {
    var popover = document.getElementById('upload-popover');
    var backdrop = document.getElementById('popover-backdrop');
    if (!popover) return;
    popover.classList.add('visible');
    if (backdrop) backdrop.classList.add('visible');
    // Re-trigger entrance animation
    popover.style.animation = 'none';
    popover.offsetHeight; // force reflow
    popover.style.animation = '';
  }

  function closePopover() {
    var popover = document.getElementById('upload-popover');
    var backdrop = document.getElementById('popover-backdrop');
    if (popover) popover.classList.remove('visible');
    if (backdrop) backdrop.classList.remove('visible');
  }

  function togglePopover() {
    var popover = document.getElementById('upload-popover');
    if (popover && popover.classList.contains('visible')) {
      closePopover();
    } else {
      openPopover();
    }
  }

  // --- Internal ---

  function showStatus() {
    var splitBtn = document.getElementById('split-btn');
    var statusBtn = document.getElementById('status-btn');
    if (splitBtn) splitBtn.classList.add('has-status');
    if (statusBtn) statusBtn.classList.add('visible');
    if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
  }

  function updateBadge() {
    var processing = document.querySelectorAll('#popover-body .pop-file.processing').length;
    var countEl = document.getElementById('status-count');
    var iconEl = document.getElementById('status-icon');
    if (!countEl || !iconEl) return;

    if (processing > 0) {
      countEl.textContent = processing;
      countEl.className = 'status-count';
      iconEl.setAttribute('icon', 'lucide:loader');
      iconEl.className = 'spin';
    } else {
      countEl.innerHTML = '<iconify-icon icon="lucide:check" style="font-size:11px"></iconify-icon>';
      countEl.className = 'status-count all-done';
      iconEl.setAttribute('icon', 'lucide:check-circle');
      iconEl.className = '';
      scheduleAutoHide();
    }
  }

  function updateSummary() {
    var body = document.getElementById('popover-body');
    var summaryEl = document.getElementById('popover-summary');
    var textEl = document.getElementById('popover-summary-text');
    var titleEl = document.getElementById('popover-title');
    if (!body || !summaryEl || !textEl) return;

    var total     = body.querySelectorAll('.pop-file').length;
    var completed = body.querySelectorAll('.pop-file.completed').length;
    var failed    = body.querySelectorAll('.pop-file.failed').length;
    var skipped   = body.querySelectorAll('.pop-file.skipped').length;
    var remaining = total - completed - failed - skipped;

    var summaryIcon = summaryEl.querySelector('iconify-icon');

    if (remaining === 0 && total > 0) {
      summaryEl.className = 'popover-summary all-done';
      if (summaryIcon) { summaryIcon.setAttribute('icon', 'lucide:check-circle'); summaryIcon.className = ''; }
      if (titleEl) titleEl.textContent = 'All uploads complete';
      var parts = [];
      if (completed > 0) parts.push(completed + ' completed');
      if (skipped > 0)   parts.push(skipped + ' discarded');
      if (failed > 0)    parts.push(failed + ' failed');
      textEl.textContent = parts.join(', ');
    } else {
      summaryEl.className = 'popover-summary';
      if (summaryIcon) { summaryIcon.setAttribute('icon', 'lucide:loader'); summaryIcon.className = 'spin'; }
      if (titleEl) titleEl.textContent = 'Processing files...';
      textEl.textContent = 'Processing ' + (completed + failed + skipped) + ' of ' + total + ' files';
    }
  }

  function scheduleAutoHide() {
    if (hideTimeout) clearTimeout(hideTimeout);
    hideTimeout = setTimeout(function () {
      var popover = document.getElementById('upload-popover');
      // Only auto-hide the status indicator if popover is closed
      if (popover && !popover.classList.contains('visible')) {
        var splitBtn = document.getElementById('split-btn');
        var statusBtn = document.getElementById('status-btn');
        if (splitBtn) splitBtn.classList.remove('has-status');
        if (statusBtn) statusBtn.classList.remove('visible');
      }
    }, 5000);
  }

  return {
    onUploadResponse: onUploadResponse,
    onItemLoad: onItemLoad,
    openPopover: openPopover,
    closePopover: closePopover,
    togglePopover: togglePopover
  };
})();

Step 2: Include upload.js in layout

In src/com/getorcha/erp/ui/layout.clj, add after line 213 (after the iconify-icon script):

[:script {:src (str "/public/js/upload.js?v=" assets-version)}]

Step 3: Verify the JS loads

Open any page, check browser DevTools console: window.orchaUpload should be defined with the expected methods.

Step 4: Commit

git add resources/erp/public/js/upload.js src/com/getorcha/erp/ui/layout.clj
git commit -m "feat: upload.js client-side module for popover state"

Task 3: Hiccup components — popover-file-item + split-upload-button

Add two new components to components.clj: a popover file item renderer (shared by upload handler and SSE handlers) and the split-upload-button that replaces file-upload on document pages.

Files:

Step 1: Add type label and icon maps

Add after the existing file-upload function (~line 258):

(def ^:private popover-type-labels
  "Human-readable labels for document types in the upload popover."
  {:invoice             "Invoice"
   :financial-notice    "Financial Notice"
   :contract            "Contract"
   :purchase-order      "Purchase Order"
   :goods-received-note "GRN"
   :other               "Other"})


(def ^:private popover-type-icons
  "Icons for document types in the upload popover."
  {:invoice             "lucide:file-text"
   :financial-notice    "lucide:alert-triangle"
   :contract            "lucide:file-signature"
   :purchase-order      "lucide:clipboard-list"
   :goods-received-note "lucide:package-check"
   :other               "lucide:file-x"})


(def ^:private popover-status-icons
  {:processing "lucide:sparkles"
   :completed  "lucide:check"
   :failed     "lucide:x"
   :skipped    "lucide:minus"})

Step 2: Add popover-file-item function

(defn popover-file-item
  "Renders a single file item for the upload progress popover.

   Options:
   - :document-id   - UUID (required)
   - :file-name     - original filename string
   - :status        - keyword :processing, :completed, :failed, or :skipped
   - :document-type - keyword like :invoice, :contract (nil if unknown)
   - :href          - link URL (nil for non-clickable states)"
  [{:keys [document-id file-name status document-type href]
    :as   _opts}]
  (let [phase      (name status)
        terminal?  (#{:completed :failed :skipped} status)
        type-kw    (some-> document-type keyword)]
    [:a (cond-> {:id    (str "pop-file-" document-id)
                 :class (str "pop-file " phase " batch-enter")
                 "hx-on::load" "orchaUpload.onItemLoad(this)"}
          href                  (assoc :href href)
          (not href)            (assoc :href "#")
          (= status :skipped)   (assoc :style "opacity:0.5;cursor:default")
          (= status :failed)    (assoc :style "cursor:default"))
     [:div {:class (str "pop-file-icon " phase)}
      [:iconify-icon {:icon (get popover-status-icons status "lucide:file-text")}]]
     [:div.pop-file-info
      [:div.pop-file-name file-name]
      [:div.pop-file-status
       (case status
         :processing [:span [:iconify-icon {:icon "lucide:loader" :class "spin"}] " Processing..."]
         :completed  [:span {:style "color: #3fb950"} "Completed"]
         :failed     [:span {:style "color: #f85149"} "Failed"]
         :skipped    [:span {:style "color: #8b949e"} "Duplicate — skipped"])]
      (when type-kw
        [:div {:class (str "pop-file-type type-" (name type-kw)
                           (when terminal? " visible"))}
         [:iconify-icon {:icon  (get popover-type-icons type-kw "lucide:file")
                         :style "font-size:11px"}]
         (get popover-type-labels type-kw (name type-kw))])
      (when-not terminal?
        [:div.pop-activity
         [:div {:class (str "pop-activity-bar " phase)}]])]]))

Step 3: Add split-upload-button function

(defn split-upload-button
  "Upload button with split status indicator and progress popover.

   When idle, shows a single 'Upload' button. During upload, a status
   indicator appears on the right showing remaining file count. Clicking
   the status indicator toggles a popover with per-file progress.

   The form uses `hx-post` with `hx-swap=\"afterbegin\"` to prepend
   upload results into the popover body. SSE events update individual
   items via OOB swaps.

   Options:
   - :id         - file input ID (required)
   - :accept     - accepted file types
   - :upload-url - POST endpoint URL
   - :max-files  - max files allowed (default 5)
   - :label      - button label (default \"Upload\")"
  [{:keys [id accept upload-url max-files label]
    :or   {max-files 5 label "Upload"}}]
  [:div.upload-wrapper

   ;; Split button
   [:div.split-btn {:id "split-btn"}

    ;; Left: Upload form — always available
    [:form {:hx-post      upload-url
            :hx-encoding  "multipart/form-data"
            :hx-target    "#popover-body"
            :hx-swap      "afterbegin"
            "hx-on::after-request" "orchaUpload.onUploadResponse(event)"}
     [:label.split-btn-upload {:for id}
      [:input (cond-> {:type     "file"
                       :id       id
                       :name     "files[]"
                       :accept   accept
                       :style    "display:none"
                       :multiple true
                       :onchange (format "if(this.files.length > %d) { alert('Maximum %d files allowed'); this.value=''; return false; } this.form.requestSubmit(); this.value='';"
                                         max-files max-files)})]
      [:iconify-icon {:icon :lucide:upload}]
      label]]

    ;; Right: Status indicator — visible when processing
    [:button.split-btn-status
     {:id      "status-btn"
      :type    "button"
      :onclick "orchaUpload.togglePopover()"}
     [:iconify-icon {:icon :lucide:loader :class "spin" :id "status-icon"}]
     [:span.status-count {:id "status-count"} "0"]]]

   ;; Click-outside backdrop
   [:div.popover-backdrop {:id "popover-backdrop" :onclick "orchaUpload.closePopover()"}]

   ;; Popover
   [:div.upload-popover {:id "upload-popover"}
    [:div.popover-header
     [:div.popover-header-left
      [:iconify-icon {:icon :lucide:upload-cloud}]
      [:span {:id "popover-title"} "Processing files..."]]
     [:button.pop-close {:type "button" :onclick "orchaUpload.closePopover()"}
      [:iconify-icon {:icon :lucide:x}]]]
    [:div.popover-body {:id "popover-body"}]
    [:div.popover-summary {:id "popover-summary"}
     [:iconify-icon {:icon :lucide:loader :class "spin"}]
     [:span {:id "popover-summary-text"} ""]]]])

Step 4: Verify via REPL

;; /clojure-eval
(require 'com.getorcha.erp.ui.components :reload)
(com.getorcha.erp.ui.components/split-upload-button
 {:id "test" :accept ".pdf" :upload-url "/upload" :max-files 5 :label "Upload"})
;; Should return well-formed Hiccup vector

Step 5: Commit

git add src/com/getorcha/erp/ui/components.clj
git commit -m "feat: popover-file-item and split-upload-button components"

Task 4: Upload handler — return HTML with popover items

Change the upload endpoint to return rendered popover file items for HTMX requests. This HTML gets afterbegin-swapped into #popover-body by the form's hx-swap. API (non-HTMX) behavior stays unchanged.

Files:

Step 1: Write the failing test

Create test/com/getorcha/erp/http/documents/upload_test.clj:

(ns com.getorcha.erp.http.documents.upload-test
  (:require [clojure.test :refer [deftest is testing]]
            [com.getorcha.erp.http.documents.upload :as upload]
            [com.getorcha.erp.http.routes :as erp.http.routes]
            [com.getorcha.erp.ingestion :as erp.ingestion]))


(defn ^:private make-upload-request
  "Builds a minimal async Ring request for the upload handler."
  [files & {:keys [htmx?] :or {htmx? true}}]
  (cond-> {:parameters {:multipart {(keyword "files[]") files}}
           :aws        {}
           :db-pool    nil
           :identity   {:identity/id (random-uuid) :tenant/id (random-uuid)}
           ::reitit.core/router nil}
    htmx? (assoc-in [:headers "hx-request"] "true")))


(deftest upload-returns-html-popover-items
  (let [doc-id-1 #uuid "11111111-1111-1111-1111-111111111111"
        doc-id-2 #uuid "22222222-2222-2222-2222-222222222222"
        call-idx (atom 0)
        response (promise)]
    (with-redefs [erp.ingestion/queue-for-ingestion!
                  (fn [_db _aws _opts]
                    (let [i (swap! call-idx inc)]
                      {:document/id  (if (= i 1) doc-id-1 doc-id-2)
                       :ingestion/id (random-uuid)
                       :created?     true}))
                  erp.http.routes/path-for
                  (fn [_ _ params]
                    (str "/documents/view/" (:document-id params)))]
      (#'upload/upload
       (make-upload-request [{:content-type "application/pdf"
                              :filename     "invoice.pdf"
                              :tempfile     (java.io.File/createTempFile "test" ".pdf")}
                             {:content-type "application/pdf"
                              :filename     "contract.pdf"
                              :tempfile     (java.io.File/createTempFile "test" ".pdf")}])
       (partial deliver response)
       identity))
    (let [{:keys [status headers body]} @response]
      (testing "returns 200 with text/html"
        (is (= 200 status))
        (is (= "text/html" (get headers "Content-Type"))))
      (testing "body contains popover items with document IDs"
        (is (re-find #"pop-file-11111111" body))
        (is (re-find #"pop-file-22222222" body)))
      (testing "body contains filenames"
        (is (re-find #"invoice\.pdf" body))
        (is (re-find #"contract\.pdf" body)))
      (testing "body contains batch separator"
        (is (re-find #"batch-separator" body))))))


(deftest upload-renders-skipped-files-as-terminal
  (let [response (promise)]
    (with-redefs [erp.ingestion/queue-for-ingestion!
                  (fn [_db _aws _opts]
                    {:document/id  (random-uuid)
                     :ingestion/id (random-uuid)
                     :skipped?     true})
                  erp.http.routes/path-for
                  (fn [_ _ params] (str "/documents/view/" (:document-id params)))]
      (#'upload/upload
       (make-upload-request [{:content-type "application/pdf"
                              :filename     "duplicate.pdf"
                              :tempfile     (java.io.File/createTempFile "test" ".pdf")}])
       (partial deliver response)
       identity))
    (let [{:keys [body]} @response]
      (testing "skipped file has skipped class"
        (is (re-find #"pop-file skipped" body)))
      (testing "skipped file shows duplicate message"
        (is (re-find #"Duplicate" body))))))

Step 2: Run test to verify it fails

clj -X:test:silent :nses '[com.getorcha.erp.http.documents.upload-test]'

Expected: FAIL — current handler returns empty string, not HTML with popover items.

Step 3: Modify the upload handler

In src/com/getorcha/erp/http/documents/upload.clj:

Add to requires:

[com.getorcha.erp.ui.components :as erp.ui.components]
[hiccup2.core :as hiccup]

Replace lines 57–68 (the if htmx-request? block) with:

    (if htmx-request?
      ;; Return HTML popover items — HTMX afterbegin-swaps them into #popover-body
      (let [now       (java.time.LocalTime/now (java.time.ZoneId/of "Europe/Berlin"))
            time-str  (.format now (java.time.format.DateTimeFormatter/ofPattern "HH:mm"))
            items-html (str (hiccup/html
                             [:div.batch-separator.batch-enter time-str])
                            (apply str
                              (map (fn [result {:keys [filename]}]
                                     (str (hiccup/html
                                           (erp.ui.components/popover-file-item
                                            {:document-id   (:document/id result)
                                             :file-name     filename
                                             :status        (if (:skipped? result) :skipped :processing)
                                             :document-type nil
                                             :href          (erp.http.routes/path-for
                                                             router
                                                             :com.getorcha.erp.http.documents.view/detail
                                                             {:document-id (:document/id result)})}))))
                                   results files)))]
        (respond (-> (ring.resp/ok items-html)
                     (ring.response/header "Content-Type" "text/html"))))
      ;; API request: return JSON (unchanged)
      (respond (ring.resp/ok (if (= 1 (count results))
                               (first results)
                               results))))))

Add the time imports at the top:

(:import (java.time LocalTime ZoneId)
         (java.time.format DateTimeFormatter))

Step 4: Run test to verify it passes

clj -X:test:silent :nses '[com.getorcha.erp.http.documents.upload-test]'

Expected: PASS

Step 5: Commit

git add src/com/getorcha/erp/http/documents/upload.clj test/com/getorcha/erp/http/documents/upload_test.clj
git commit -m "feat: upload endpoint returns HTML popover items for HTMX requests"

Task 5: AP page — wire up split button + SSE changes

Replace the old file-upload with split-upload-button on the Accounts Payable page. Update the SSE exec-fn to:

  1. Include OOB popover item updates in SSE events
  2. Only show table rows for completed documents whose type matches the AP page
  3. Skip heavy DB queries for in-progress events

Files:

Step 1: Define AP document types

Add near the top of the file (after the helpers section, ~line 30):

(def ^:private ap-document-types
  "Document types shown on the Accounts Payable page."
  #{:invoice :financial-notice})

Step 2: Replace the upload button

At ~line 573, replace the erp.ui.components/file-upload call with:

       (erp.ui.components/split-upload-button
        {:id         "doc-upload"
         :accept     "application/pdf,image/*,.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
         :upload-url (erp.http.routes/path-for router :com.getorcha.erp.http.documents.upload/upload)
         :max-files  5
         :label      "Upload"})

Step 3: Update the SSE HTML structure

The SSE div (~line 620) stays mostly the same — it already uses sse-swap="new-document", hx-target, and hx-swap="afterbegin". Keep it as-is. The new behavior comes from the exec-fn returning HTML that includes OOB elements alongside the primary swap content.

No changes needed to the HTML structure.

Step 4: Rewrite the SSE exec-fn

Replace the exec-fn (lines 1192–1245) with:

         (fn [event]
           ;; Skip events for other legal entities
           (when (contains? le-id-set (:legal-entity/id event))
             (let [{event-type :event/type :document/keys [id]} event]
               (case event-type
                 :ingestion
                 (let [{:ingestion/keys [status]} event]
                   (case (keyword status)
                     ;; In-progress: skip (popover already shows "Processing...", no table row yet)
                     :in-progress nil

                     ;; Completed: update popover + maybe add/update table row
                     :completed
                     (let [document (db.sql/execute-one!
                                     db-pool
                                     {:select    [:document.id
                                                  :document.type
                                                  :document.file-original-name
                                                  :document.created-at :document.structured-data
                                                  :document.needs-human-review
                                                  :document.matching-status
                                                  :document.legal-entity-id
                                                  [:legal-entity.name :legal-entity-name]
                                                  :latest-ingestion.status
                                                  [:latest-export.status :export-status]
                                                  [:latest-sv.risk-rating :sv-risk-rating]
                                                  ;; Count of completed ingestions (for new-vs-update detection)
                                                  [[{:select [[:%count.* :cnt]]
                                                     :from   [:ingestion]
                                                     :where  [:and
                                                              [:= :ingestion.document-id :document.id]
                                                              [:= :ingestion.status "completed"]]}]
                                                   :completed-count]]
                                      :from      [:document]
                                      :join      [[:legal-entity] [:= :legal-entity.id :document.legal-entity-id]]
                                      :left-join shared/lateral-joins
                                      :where     [:and
                                                  [:= :document.id id]
                                                  [:in :document.legal-entity-id (vec le-id-set)]]}
                                     {:builder-fn shared/document-builder-fn})
                           doc-type         (some-> (:document/type document) keyword)
                           type-matches?    (contains? ap-document-types doc-type)
                           first-completed? (= 1 (:completed-count document 0))
                           match-counts     (db.document-matching/get-match-counts-for-documents db-pool [id])
                           default-state    {:status-filter "all" :sort-by :received :sort-dir :desc :page 1}
                           selection        (get-selection (:session request))
                           opts             {:has-datev? has-datev? :list-state default-state :selection selection
                                             :show-legal-entity? show-legal-entity? :match-counts match-counts}

                           ;; OOB popover item (always included — no-ops if no popover item in DOM)
                           popover-html     (str (hiccup/html
                                                  (-> (erp.ui.components/popover-file-item
                                                       {:document-id   id
                                                        :file-name     (:document/file-original-name document)
                                                        :status        :completed
                                                        :document-type doc-type
                                                        :href          (erp.http.routes/path-for
                                                                        router
                                                                        :com.getorcha.erp.http.documents.view/detail
                                                                        {:document-id id})})
                                                      (assoc-in [1 :hx-swap-oob] "outerHTML"))))

                           ;; Table row (only if type matches this page)
                           row-html         (when type-matches?
                                              (let [row (document-row router opts document)]
                                                (str (hiccup/html
                                                      (cond-> row
                                                        ;; First completed: primary swap (afterbegin, no OOB)
                                                        ;; Re-ingestion: OOB update (replaces existing row by ID)
                                                        (not first-completed?)
                                                        (-> (update 1 update :class #(str % " row-new"))
                                                            (assoc-in [1 :hx-swap-oob] "true"))
                                                        first-completed?
                                                        (update 1 update :class #(str % " row-new")))))))]

                       {:event "new-document"
                        :data  (str popover-html row-html)})

                     ;; Failed: update popover item only, no table row
                     :failed
                     (let [document (db.sql/execute-one!
                                     db-pool
                                     {:select [:document.id :document.type :document.file-original-name]
                                      :from   [:document]
                                      :where  [:= :document.id id]}
                                     {:builder-fn shared/document-builder-fn})
                           doc-type (some-> (:document/type document) keyword)]
                       {:event "new-document"
                        :data  (str (hiccup/html
                                     (-> (erp.ui.components/popover-file-item
                                          {:document-id   id
                                           :file-name     (:document/file-original-name document)
                                           :status        :failed
                                           :document-type doc-type
                                           :href          nil})
                                         (assoc-in [1 :hx-swap-oob] "outerHTML"))))})

                     ;; Unknown ingestion status — skip
                     nil))

                 :export
                 ;; Export status changed — update existing row via OOB swap (same as before)
                 (let [document    (db.sql/execute-one!
                                    db-pool
                                    {:select    [:document.id
                                                 :document.type
                                                 :document.created-at :document.structured-data
                                                 :document.needs-human-review
                                                 :document.matching-status
                                                 :document.legal-entity-id
                                                 [:legal-entity.name :legal-entity-name]
                                                 :latest-ingestion.status
                                                 [:latest-export.status :export-status]
                                                 [:latest-sv.risk-rating :sv-risk-rating]]
                                     :from      [:document]
                                     :join      [[:legal-entity] [:= :legal-entity.id :document.legal-entity-id]]
                                     :left-join shared/lateral-joins
                                     :where     [:and
                                                 [:= :document.id id]
                                                 [:in :document.legal-entity-id (vec le-id-set)]]}
                                    {:builder-fn shared/document-builder-fn})
                       match-counts (db.document-matching/get-match-counts-for-documents db-pool [id])
                       default-state {:status-filter "all" :sort-by :received :sort-dir :desc :page 1}
                       selection    (get-selection (:session request))
                       opts         {:has-datev? has-datev? :list-state default-state :selection selection
                                     :show-legal-entity? show-legal-entity? :match-counts match-counts}
                       row          (document-row router opts document)]
                   {:event "new-document"
                    :data  (hiccup/html (assoc-in row [1 :hx-swap-oob] "true"))})

                 :matching
                 ;; Matching status changed — update existing row via OOB swap
                 (let [document    (db.sql/execute-one!
                                    db-pool
                                    {:select    [:document.id
                                                 :document.type
                                                 :document.created-at :document.structured-data
                                                 :document.needs-human-review
                                                 :document.matching-status
                                                 :document.legal-entity-id
                                                 [:legal-entity.name :legal-entity-name]
                                                 :latest-ingestion.status
                                                 [:latest-export.status :export-status]
                                                 [:latest-sv.risk-rating :sv-risk-rating]]
                                     :from      [:document]
                                     :join      [[:legal-entity] [:= :legal-entity.id :document.legal-entity-id]]
                                     :left-join shared/lateral-joins
                                     :where     [:and
                                                 [:= :document.id id]
                                                 [:in :document.legal-entity-id (vec le-id-set)]]}
                                    {:builder-fn shared/document-builder-fn})
                       match-counts (db.document-matching/get-match-counts-for-documents db-pool [id])
                       default-state {:status-filter "all" :sort-by :received :sort-dir :desc :page 1}
                       selection    (get-selection (:session request))
                       opts         {:has-datev? has-datev? :list-state default-state :selection selection
                                     :show-legal-entity? show-legal-entity? :match-counts match-counts}
                       row          (document-row router opts document)]
                   {:event "new-document"
                    :data  (hiccup/html (assoc-in row [1 :hx-swap-oob] "true"))})

                 ;; Unknown event type — skip
                 nil))))

Step 5: Add erp.ui.components require if not present

The AP file already requires com.getorcha.erp.ui.components :as erp.ui.components (line 14). No change needed.

Step 6: Manual integration test

  1. Open the AP page in the browser
  2. Upload 2-3 files of mixed types
  3. Confirm: popover opens with "Processing..." items
  4. Confirm: when an invoice completes, a row slides into the table
  5. Confirm: when a contract completes, no row appears but popover shows "Completed" with "Contract" type tag
  6. Confirm: badge count decreases as files complete
  7. Confirm: summary shows "N completed, M discarded" when all done
  8. Confirm: status indicator auto-hides ~5s after all done (if popover closed)

Step 7: Commit

git add src/com/getorcha/erp/http/documents/accounts_payable.clj
git commit -m "feat: split upload button and type-filtered SSE on AP page"

Task 6: DM page — add SSE + wire up split button

The Document Management page currently has no SSE. Add it following the AP pattern, then replace the upload button.

Files:

Step 1: Add required imports

Add to the ns requires:

[clojure.core.async :as a]
[com.getorcha.erp.http.sse :as erp.http.sse]

Step 2: Define DM document types

Add near the top:

(def ^:private dm-document-types
  "Document types shown on the Document Management page."
  #{:contract :purchase-order :goods-received-note})

Step 3: Add the SSE handler

Add before the routes function. Model it closely on the AP handler from Task 5, but with dm-document-types and DM's document-row function. The exec-fn logic is the same:

Key differences from AP:

(defn ^:private list-events
  "SSE endpoint for document management page - sends updates for new/changed documents."
  [{:keys [db-pool identity document-events ::reitit/router] :as _request}
   respond
   _raise]
  (let [tenant-id          (:tenant/id identity)
        le-id-set          (set (shared/legal-entity-ids _request))
        show-legal-entity? (> (count le-id-set) 1)]
    (respond
     (let [sub (doto (a/chan 10)
                 (->> (a/sub document-events tenant-id)))]
       (erp.http.sse/looper
        {:input-ch    sub
         :clean-up-fn #(a/unsub document-events tenant-id sub)
         :exec-fn
         (fn [event]
           (when (contains? le-id-set (:legal-entity/id event))
             ;; ... same pattern as AP Task 5, using dm-document-types
             ;; and DM's document-row function
             ))})))))

For the full exec-fn implementation: duplicate the AP pattern from Task 5 Step 4, replacing:

Step 4: Add SSE HTML to the DM page

In the list-documents handler, add the SSE div right before the document list table ([:table#doc-mgmt-list.table at ~line 207):

       ;; SSE connection for real-time updates
       [:div {:hx-ext      "sse"
              :sse-connect (erp.http.routes/path-for router ::list-events)
              :sse-swap    "new-document"
              :hx-target   "#doc-mgmt-list tbody"
              :hx-swap     "afterbegin"}]

Step 5: Replace file-upload with split-upload-button

At ~line 187, replace the erp.ui.components/file-upload call with:

        (erp.ui.components/split-upload-button
         {:id         "doc-mgmt-upload"
          :accept     "application/pdf,image/*,.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
          :upload-url (erp.http.routes/path-for router :com.getorcha.erp.http.documents.upload/upload)
          :max-files  5
          :label      "Upload"})

Step 6: Add SSE route

Add to the routes definition (~line 428):

["/management"
 [""
  {:name ::list
   :get  {...}}]
 ["/events"
  {:name ::list-events
   :get  {:summary "SSE events for document management list"
          :handler #'list-events}}]
 ["/search"
  {:name ::search
   :get  {...}}]]

Step 7: Ensure document-events is injected into management requests

Check that the management routes receive the document-events key in the request map. This is typically done via middleware or Integrant injection. Look at how it's done for accounts_payable.clj routes and replicate the same pattern for management.

If document-events is injected at the /documents route group level, no change is needed. If it's only injected for /accounts-payable, extend the injection.

Step 8: Manual test

Same as Task 5 Step 6, but on the Document Management page. Confirm:

Step 9: Commit

git add src/com/getorcha/erp/http/documents/management.clj
git commit -m "feat: add SSE and split upload button to Document Management page"

Task 7: Extract shared SSE exec-fn logic

The AP and DM SSE exec-fns have significant duplication. Extract the common logic into a shared function.

Files:

Step 1: Assess duplication

Compare the AP and DM exec-fns. They differ only in:

Step 2: Extract shared function

In shared.clj, add:

(defn document-list-sse-exec-fn
  "Creates an SSE exec-fn for document list pages.

   Handles ingestion, export, and matching events. Sends OOB popover item
   updates and table rows (new or updated) filtered by page document types.

   Parameters:
   - db-pool, router, le-id-set — standard context
   - page-types — set of document type keywords shown on this page
   - render-row-fn — (fn [router opts document]) → Hiccup table row
   - build-opts-fn — (fn [match-counts]) → opts map for render-row-fn"
  [db-pool router le-id-set page-types render-row-fn build-opts-fn]
  (fn [event]
    (when (contains? le-id-set (:legal-entity/id event))
      ;; ... shared logic from Task 5 exec-fn ...
      )))

Step 3: Refactor AP and DM to use shared fn

In AP:

:exec-fn (shared/document-list-sse-exec-fn
          db-pool router le-id-set
          ap-document-types
          document-row
          (fn [match-counts]
            {:has-datev? has-datev? :list-state default-state
             :selection selection :show-legal-entity? show-legal-entity?
             :match-counts match-counts}))

In DM:

:exec-fn (shared/document-list-sse-exec-fn
          db-pool router le-id-set
          dm-document-types
          document-row
          (fn [match-counts]
            {:list-state default-state :show-legal-entity? show-legal-entity?
             :match-counts match-counts}))

Step 4: Verify both pages work unchanged

Open both pages, upload files, confirm SSE behavior is identical to before refactoring.

Step 5: Commit

git add src/com/getorcha/erp/http/documents/shared.clj src/com/getorcha/erp/http/documents/accounts_payable.clj src/com/getorcha/erp/http/documents/management.clj
git commit -m "refactor: extract shared SSE exec-fn for document list pages"

Task 8: Lint + final verification

Step 1: Run linter

clj-kondo --lint src test dev

Fix all issues.

Step 2: Run full test suite

clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"

Step 3: Manual smoke test

Step 4: Commit any fixes

git add <specific files>
git commit -m "fix: address lint and test issues from upload feature"