Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
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:
hx-swap-oob="outerHTML" — replace a specific popover item by ID from SSE eventsafterbegin) plus OOB popover itemhx-on::after-request — JS hook after upload completes to open popoverhx-on::load — JS hook when SSE replaces a popover item (triggers badge recount)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
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:
resources/erp/public/css/style.csstmp-ui-sandbox/approach-A/button-indicator-v3.html (lines 168–477)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:
.upload-wrapper — relative positioning for popover anchor.split-btn, .split-btn-upload, .split-btn-status, .status-count — split button layout (prototype lines 172–246).upload-popover, .popover-header, .popover-body, .popover-summary, .pop-close, @keyframes popover-enter — popover structure (lines 248–314).batch-separator — batch grouping divider (lines 316–335).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).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).pop-activity, .pop-activity-bar, @keyframes activity-slide, .pop-activity.done — activity animation (lines 404–440).popover-backdrop — click-outside overlay (lines 456–463).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"
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:
resources/erp/public/js/upload.jssrc/com/getorcha/erp/ui/layout.clj (~line 213)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"
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:
src/com/getorcha/erp/ui/components.cljStep 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"
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:
src/com/getorcha/erp/http/documents/upload.cljtest/com/getorcha/erp/http/documents/upload_test.clj (create)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"
Replace the old file-upload with split-upload-button on the Accounts Payable page. Update the SSE exec-fn to:
Files:
src/com/getorcha/erp/http/documents/accounts_payable.cljStep 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
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"
The Document Management page currently has no SSE. Add it following the AP pattern, then replace the upload button.
Files:
src/com/getorcha/erp/http/documents/management.cljStep 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:
:in-progress → nil (skip):completed → popover OOB + table row (if type matches DM) with dm-document-types:failed → popover OOB only:export → OOB row update:matching → OOB row updateKey differences from AP:
:has-datev? or :selection in optsdocument-row renderer (which has different columns):completed document query needs file-original-name (already used by DM's document-row)dm-document-types(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:
ap-document-types → dm-document-typeshas-datev? → false (or remove from opts)selection → #{} (or remove from opts)document-row → DM's document-rowdocument-row needs (includes :document.file-original-name, :document.matching-status)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"
The AP and DM SSE exec-fns have significant duplication. Extract the common logic into a shared function.
Files:
src/com/getorcha/erp/http/documents/shared.cljStep 1: Assess duplication
Compare the AP and DM exec-fns. They differ only in:
page-document-types setdocument-row rendering functionopts map (AP has has-datev? and selection; DM doesn't)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"
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"