Document Matching UI Implementation Plan

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

Goal: Surface document matches on the detail page so users can navigate between matched documents.

Architecture: Fetch matches in the existing get-document handler via a new DB query that joins document_match with document to get matched document metadata. Pass matches through opts to the renderer. Add a "Matches" tab and collapsible section at the top of the content area, conditionally rendered only when matches exist.

Tech Stack: Clojure, HoneySQL, Hiccup, HTMX (existing patterns)


Task 1: New DB query — fetch matched documents with metadata

Files:

The existing get-matches-for-document selects only from document_match and orders by a nonexistent :confidence column. Replace it with a query that joins the matched document to return its type, structured-data, and file-original-name.

Step 1: Replace get-matches-for-document

The query must handle canonical ordering (a < b) — the current document could be either document_a_id or document_b_id. Use a UNION approach via two queries combined, or use a CASE expression. The simplest approach: query both sides with a union-style OR, then use a subselect/join to get the other document's info.

Replace lines 60-70 in document_matching.clj:

(defn get-matched-documents
  "Get documents matched to `document-id`, with their type and structured data
   for display. Returns document rows for the *other* side of each match edge."
  [db document-id]
  (db.sql/execute! db
    {:union-all
     [{:select   [[:matched.id :document/id]
                  [:matched.type :document/type]
                  [:matched.structured-data :document/structured-data]
                  [:matched.file-original-name :document/file-original-name]
                  [:document-match.blended-score :document-match/blended-score]]
       :from     [:document-match]
       :join     [[:document :matched] [:= :matched.id :document-match.document-b-id]]
       :where    [:= :document-match.document-a-id document-id]}
      {:select   [[:matched.id :document/id]
                  [:matched.type :document/type]
                  [:matched.structured-data :document/structured-data]
                  [:matched.file-original-name :document/file-original-name]
                  [:document-match.blended-score :document-match/blended-score]]
       :from     [:document-match]
       :join     [[:document :matched] [:= :matched.id :document-match.document-a-id]]
       :where    [:= :document-match.document-b-id document-id]}]}))

Step 2: Verify in REPL

(require '[com.getorcha.db.document-matching :as db.matching] :reload)
(def db (:com.getorcha.db/pool integrant.repl.state/system))
(db.matching/get-matched-documents db (parse-uuid "019c9dfe-af27-70f6-909d-2f649b13ef5e"))
;; Expected: vector with one map containing :document/id, :document/type, :document/structured-data, :document/file-original-name, :document-match/blended-score

Step 3: Commit

git add src/com/getorcha/db/document_matching.clj
git commit -m "feat(matching): add get-matched-documents query with document metadata"

Task 2: Handler — fetch matches and pass to renderer

Files:

Step 1: Add require

Add [com.getorcha.db.document-matching :as db.matching] to the ns requires (alphabetically ordered).

Step 2: Fetch matches in handler

Inside the let block of get-document (after the supplier-verification binding around line 187), add:

matches (db.matching/get-matched-documents db-pool (:document/id document))

Step 3: Pass matches in opts

Add :matches matches to the opts map passed to view.shared/detail-page (around line 198-204):

{:tenants                tenants
 :current-tenant-name    current-tenant-name
 :is-super-admin?        is-super-admin?
 :in-qa-dataset?         in-qa-dataset?
 :has-datev-connection?  has-datev-connection?
 :export-audit           export-audit
 :list-state             list-state
 :excel-preview          excel-preview
 :supplier-verification  supplier-verification
 :matches                matches}

Step 4: Commit

git add src/com/getorcha/erp/http/documents/view.clj
git commit -m "feat(matching): fetch matches in document detail handler"

Task 3: Thread matches through rendering pipeline

Files:

The matches must flow through: detail-pagedetail-area-contentdetail-page-content.

Step 1: Destructure matches in detail-page

In detail-page (line 480), add :matches to the opts destructuring:

(let [{:keys [tenants current-tenant-name is-super-admin? in-qa-dataset?
              has-datev-connection? export-audit list-state excel-preview supplier-verification matches]} opts

And pass it through to detail-area-content — add :matches matches to the opts map at line 525:

(detail-area-content router document {:is-super-admin?        is-super-admin?
                                       :in-qa-dataset?         in-qa-dataset?
                                       :has-datev-connection?  has-datev-connection?
                                       :export-audit           export-audit
                                       :awaiting-auto-export?  false
                                       :excel-preview          excel-preview
                                       :supplier-verification  supplier-verification
                                       :matches                matches})

Step 2: Commit

git add src/com/getorcha/erp/http/documents/view/shared.clj
git commit -m "feat(matching): thread matches through rendering pipeline"

Task 4: Add "Matches" tab to section tabs

Files:

Step 1: Add matches parameter to section-tabs-for-type

Change the signature from:

(defn ^:private section-tabs-for-type
  "Returns section navigation tabs appropriate for the document type."
  [doc-type structured-data has-datev-connection?]

To:

(defn ^:private section-tabs-for-type
  "Returns section navigation tabs appropriate for the document type."
  [doc-type structured-data has-datev-connection? matches]

At the end of each case branch's (list ...), append:

(when (seq matches)
  (tab "section-matches" "Matches"))

For the :invoice branch (line 69-78), change to:

:invoice
(list
 (when (seq matches)
   (tab "section-matches" "Matches"))
 (when has-datev-connection?
   (tab "section-datev-export" "Export"))
 (tab "section-invoice-details" "Details")
 (tab "section-validation" "Validation")
 (when (or (:shipping-address structured-data) (:incoterm-code structured-data))
   (tab "section-delivery" "Delivery"))
 (when (:line-items structured-data)
   (tab "section-line-items" "Items"))
 (tab "section-payment-summary" "Payment"))

Apply the same pattern for :contract, :purchase-order, and :goods-received-note — add the matches tab as the first item in each list.

Step 2: Update the call site in detail-page-content

Around line 286, the call is:

(when-let [tabs (section-tabs-for-type doc-type structured-data has-datev-connection?)]

The opts map is already destructured at the top of this function. Add :matches to the destructuring of opts (around line 158):

{:keys [is-super-admin? in-qa-dataset? has-datev-connection? export-audit awaiting-auto-export? excel-preview supplier-verification matches]
 :as   _opts}

Then update the call:

(when-let [tabs (section-tabs-for-type doc-type structured-data has-datev-connection? matches)]

Step 3: Commit

git add src/com/getorcha/erp/http/documents/view/shared.clj
git commit -m "feat(matching): add Matches tab to section navigation"

Task 5: Render matches section

Files:

Step 1: Add the display-name helper and matches section

Add a require for com.getorcha.erp.http.documents.shared (it's already required as shared). Add a require for com.getorcha.erp.http.routes (already required as erp.http.routes).

Add this function before detail-page-content:

(defn ^:private match-display-name
  "Extracts a human-readable display name from a matched document."
  [{:document/keys [type structured-data file-original-name] :as _matched-doc}]
  (let [doc-type (keyword type)]
    (case doc-type
      :invoice          (or (:invoice-number structured-data)
                            (get-in structured-data [:issuer :name])
                            file-original-name
                            "Invoice")
      :contract         (or (:title structured-data)
                            (:contract-number structured-data)
                            file-original-name
                            "Contract")
      :purchase-order   (or (:po-number structured-data)
                            file-original-name
                            "Purchase Order")
      :goods-received-note (or (:grn-number structured-data)
                               file-original-name
                               "GRN")
      :financial-notice (or (some-> (:notice-type structured-data)
                                    (string/replace "-" " ")
                                    string/capitalize)
                            file-original-name
                            "Notice")
      (or file-original-name "Document"))))


(defn ^:private matches-section
  "Renders the document matches section with cards linking to matched documents."
  [router matches]
  (erp.ui.components/collapsible-section
   "Matches"
   "section-matches"
   [:div.matches-list
    (for [{:document/keys [id type] :as matched-doc} matches]
      (let [doc-type   (keyword type)
            type-label (get shared/management-type-labels (name doc-type)
                            (case doc-type
                              :invoice          "Invoice"
                              :financial-notice  "Notice"
                              (string/capitalize (name doc-type))))
            view-url   (erp.http.routes/path-for router :com.getorcha.erp.http.documents.view/detail {:document-id id})]
        [:a.match-card {:href view-url}
         [:span {:class (str "badge badge-" (name doc-type))} type-label]
         [:span.match-title (match-display-name matched-doc)]
         [:iconify-icon {:icon "lucide:arrow-up-right" :style "font-size: 14px;"}]]))]))

Step 2: Render matches section in detail-page-content

In the .panel-body section (around line 289-305), insert the matches section before type-specific-view. Change:

[:div.panel-body
 (if processing?
   ;; Processing state
   (erp.ui.components/spinner "Processing document...")

   ;; Extracted data display
   (if structured-data
     (or (type-specific-view ...)
         [:div.empty-state
          [:p "Unknown document type"]])
     [:div.empty-state
      [:p "No data extracted yet."]]))]

To:

[:div.panel-body
 (if processing?
   ;; Processing state
   (erp.ui.components/spinner "Processing document...")

   ;; Extracted data display
   (list
    (when (seq matches)
      (matches-section router matches))
    (if structured-data
      (or (type-specific-view ...)
          [:div.empty-state
           [:p "Unknown document type"]])
      [:div.empty-state
       [:p "No data extracted yet."]])))]

Step 3: Commit

git add src/com/getorcha/erp/http/documents/view/shared.clj
git commit -m "feat(matching): render matches section on document detail page"

Task 6: Add CSS for match cards

Files:

Step 1: Find the CSS file

grep -rl "collapsible-section" resources/public/

or

find resources/public -name "*.css" | head -20

Step 2: Add match card styles

/* Document Matches */
.matches-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.match-card {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  background: var(--bg-secondary, #f6f8fa);
  border: 1px solid var(--border-color, #d0d7de);
  border-radius: 6px;
  text-decoration: none;
  color: inherit;
  transition: background-color 0.15s;
}

.match-card:hover {
  background: var(--bg-hover, #eaeef2);
}

.match-card .match-title {
  flex: 1;
  font-weight: 500;
}

.match-card iconify-icon {
  color: var(--text-secondary, #656d76);
}

Step 3: Commit

git add <css-file>
git commit -m "feat(matching): add match card CSS styles"

Task 7: Manual verification

Step 1: Reset system and test

Reload the system to pick up all changes:

(integrant.repl/reset)

Step 2: Navigate to the test invoice

Open the browser and navigate to: /documents/view/019c9dfe-af27-70f6-909d-2f649b13ef5e

Expected:

Step 3: Test a document with no matches

Navigate to any document that has no matches (check with a quick DB query).

Expected:

Step 4: Commit and lint

clj-kondo --lint src test dev --fail-level warning