Admin Panel Refactor: Organization-Grouped Tenant Management — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restructure the admin panel so the top-level page is an expandable table of organizations with nested tenant rows, and each tenant gets a dedicated detail page consolidating all configuration/stats sections.

Architecture: Server-rendered Hiccup + HTMX. The /organizations page lazily expands into nested tenant rows via HTMX row swaps. /organizations/-/tenants/:id is a full page with 12 stacked sections, most of which show a summary + link to the canonical editor elsewhere; only identity (inline PUT-per-field) and API keys (create/revoke) are editable on the page. New DB queries aggregate per-organization and per-tenant stats; no schema changes.

Tech Stack: Clojure, Hiccup, HTMX 2, Reitit, PostgreSQL, next-jdbc, Malli, clj-kondo.

Testing approach: The spec explicitly opts out of new HTTP-handler tests ("Admin has no HTTP-handler tests today. This sub-project does not introduce them."). Each task therefore uses: implement → clj-kondo clean → manual REPL/browser verification → commit. clj-kondo --lint src test dev must be info-level-clean before every commit.

Infrastructure facts the executor should know:

Prerequisite: Sub-project 1 (legal_entity → tenant rename) must be merged first. Post-SP1 schema:


File Structure

Files created or modified in this plan:

File Responsibility State
src/com/getorcha/admin/http/organizations.clj Top-level /organizations page + expansion endpoint + org/tenant CRUD (create/edit identity only) Heavily modified
src/com/getorcha/admin/http/tenants.clj NEW. Dedicated tenant detail page (/organizations/-/tenants/:id) with 12 sections, left-rail nav, inline-edit for identity Created
src/com/getorcha/admin/http.clj Register the new tenant-detail routes under ALB-auth middleware Modified
src/com/getorcha/admin/db/queries.clj Extend organization-overview with doc/review/activity aggregates; add tenants-by-organization; add tenant-detail summary queries; remove prompt-customizations-matrix Modified
src/com/getorcha/admin/http/tenants/file_store.clj Unchanged; linked-to from detail page Unchanged
src/com/getorcha/admin/http/tenants/prompt_customizations.clj Unchanged; linked-to from detail page Unchanged
src/com/getorcha/admin/ui/components.clj May add one small helper (anchor-nav) if useful; otherwise untouched Light touch

Why these boundaries:


Task Ordering

Tasks 1-6 deliver working software: a new top-level /organizations page with expansion. Detail-page links 404 until Task 7. Tasks 7-9 deliver a usable detail-page scaffold with identity editing. Tasks 10-21 add one section at a time; each section works end-to-end when its task lands. Task 22 is final cleanup.


Task 1: Remove the prompt-customizations matrix from top-level page

The matrix moves to the detail page (Task 15). Delete the query + rendering code up-front so the page doesn't regress on the way to the new layout.

Files:

Delete the (defn prompt-customizations-matrix ...) form (search for the symbol) and the ;; Prompt Customizations Matrix banner comment immediately above it.

Delete these top-level forms (line ranges approximate, confirm by searching):

In render-organizations-page:

Run: clj-kondo --lint src test dev Expected: no new warnings or errors. (If queries alias becomes unused anywhere, delete the stale require.)

In the REPL with the system running:

(require '[integrant.repl.state :as state])
(def handler (::com.getorcha.admin.http/handler state/system))
(handler {:request-method :get :uri "/organizations" :headers {}} prn prn)

Expected: a 200 response. Visit /organizations in a browser — the page still shows Organizations and Tenants sections, no matrix.

git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/organizations.clj
git commit -m "refactor(admin): remove prompt-customizations matrix from top-level page"

Task 2: Extend organization-overview query with doc/review/activity aggregates

The spec requires org-row columns for "Docs (last 30d)", "Review backlog", and "Last activity". The existing query only returns tenant_count + user_count.

Files:

Replace the existing organization-overview defn with:

(defn organization-overview
  "Get all organizations with tenant/user counts and aggregate activity stats.

   Aggregates roll up across all tenants in the org:
   - document-count-30d: documents created in the last 30 days
   - review-backlog: documents with needs_human_review = true
   - last-activity: MAX(document.updated_at) across the org's tenants"
  [db-pool]
  (jdbc/execute!
   db-pool
   ["SELECT
       organization.id,
       organization.name,
       organization.slug,
       COUNT(DISTINCT tenant.id) as tenant_count,
       COUNT(DISTINCT organization_membership.identity_id) as user_count,
       COUNT(DISTINCT CASE WHEN document.created_at > NOW() - INTERVAL '30 days'
                           THEN document.id END) as document_count_30d,
       COUNT(DISTINCT CASE WHEN document.needs_human_review = true
                           THEN document.id END) as review_backlog,
       MAX(document.updated_at) as last_activity
     FROM organization organization
     LEFT JOIN tenant tenant ON tenant.organization_id = organization.id
     LEFT JOIN organization_membership organization_membership
            ON organization_membership.organization_id = organization.id
     LEFT JOIN document document ON document.tenant_id = tenant.id
     GROUP BY organization.id, organization.name, organization.slug
     ORDER BY organization.name ASC"]))

Run: clj-kondo --lint src test dev Expected: clean.

(require '[com.getorcha.admin.db.queries :as q])
(q/organization-overview (:com.getorcha.db/pool integrant.repl.state/system))

Expected: each row has keys :organization/id, :organization/name, :organization/slug, :tenant-count, :user-count, :document-count-30d, :review-backlog, :last-activity (with :last-activity possibly nil for empty orgs).

git add src/com/getorcha/admin/db/queries.clj
git commit -m "feat(admin): add docs/review/activity aggregates to organization-overview"

Task 3: Add tenants-by-organization query for expansion rows

The expansion endpoint will call this to fetch just the tenants under one organization with their per-tenant stats.

Files:

Add the following defn immediately below the existing tenant-overview defn:

(defn tenants-by-organization
  "Get tenants for a single organization with per-tenant stats.

   Used by the admin top-level page expansion to lazy-load tenant rows
   when an organization is expanded. Stats match the nested-row spec:
   name, country, docs, review, active/total sources, DATEV, last-activity."
  [db-pool organization-id]
  (jdbc/execute!
   db-pool
   ["SELECT
       tenant.id,
       tenant.name,
       tenant.company_country,
       COUNT(DISTINCT document.id) as document_count,
       COUNT(DISTINCT CASE WHEN document.needs_human_review = true
                           THEN document.id END) as review_backlog,
       COUNT(DISTINCT ap_doc_source.id) as source_count,
       COUNT(DISTINCT CASE WHEN ap_doc_source.is_active = true
                           THEN ap_doc_source.id END) as active_sources,
       CASE WHEN MAX(CASE WHEN integration.is_active = true THEN 1 ELSE 0 END) = 1
            THEN true ELSE false END as datev_active,
       MAX(document.updated_at) as last_activity
     FROM tenant tenant
     LEFT JOIN document document ON document.tenant_id = tenant.id
     LEFT JOIN ap_doc_source ap_doc_source ON ap_doc_source.tenant_id = tenant.id
     LEFT JOIN tenant_datev_integration integration
            ON integration.tenant_id = tenant.id
           AND integration.integration_type = 'datev'
     WHERE tenant.organization_id = ?
     GROUP BY tenant.id, tenant.name, tenant.company_country
     ORDER BY tenant.name ASC"
    organization-id]))

Run: clj-kondo --lint src test dev Expected: clean.

(require '[com.getorcha.admin.db.queries :as q])
(let [pool (:com.getorcha.db/pool integrant.repl.state/system)
      org-id (:organization/id (first (q/organization-overview pool)))]
  (q/tenants-by-organization pool org-id))

Expected: one row per tenant in that organization, with keys :tenant/id, :tenant/name, :tenant/company-country, :document-count, :review-backlog, :source-count, :active-sources, :datev-active, :last-activity.

git add src/com/getorcha/admin/db/queries.clj
git commit -m "feat(admin): add tenants-by-organization query for row expansion"

Task 4: Rework the top-level organization-row with chevron + new columns

Update the organization row in organizations.clj to render the new column set (Name, Slug, Tenants, Users, Docs, Review, Last activity, Actions) and a chevron that will trigger expansion.

Files:

Place it after the country-options def. Requires java.time.Instant, ZoneId, DateTimeFormatter, ChronoUnit — add an :import form to the ns block:

(:import (java.time Instant ZoneId)
         (java.time.format DateTimeFormatter)
         (java.time.temporal ChronoUnit))
(def ^:private relative-formatter
  (DateTimeFormatter/ofPattern "yyyy-MM-dd HH:mm"))


(defn ^:private format-relative-timestamp
  "Format a timestamp as relative text, falling back to an absolute value.
   Accepts java.time.Instant, java.sql.Timestamp, or nil."
  [ts]
  (when ts
    (let [^Instant instant (if (instance? Instant ts) ts (.toInstant ts))
          now     (Instant/now)
          seconds (.between ChronoUnit/SECONDS instant now)
          minutes (quot seconds 60)
          hours   (quot minutes 60)
          days    (quot hours 24)]
      (cond
        (< seconds 60) "just now"
        (< minutes 60) (str minutes "m ago")
        (< hours 24)   (str hours "h ago")
        (< days 30)    (str days "d ago")
        :else          (.format relative-formatter
                                (.atZone instant (ZoneId/systemDefault)))))))

Replace the existing organization-row defn with:

(defn ^:private organization-row
  "Render the collapsed top-level row for an organization.

   Rendered as two sibling rows: the data row and a placeholder tr
   (`<tr id=\"org-expansion-:id\">`) where expansion content will swap in."
  [{:organization/keys [id name slug] :as organization}]
  (let [tenant-count       (or (:tenant-count organization) 0)
        user-count         (or (:user-count organization) 0)
        document-count-30d (or (:document-count-30d organization) 0)
        review-backlog     (or (:review-backlog organization) 0)
        last-activity      (:last-activity organization)
        expansion-target   (str "#org-expansion-" id)]
    (list
     [:tr {:id (str "organization-row-" id)}
      [:td.expand-cell
       [:button.btn-icon.expand-chevron
        {:type      "button"
         :title     "Expand tenants"
         :hx-get    (str "/organizations/" id "/expanded-tenants")
         :hx-target expansion-target
         :hx-swap   "outerHTML"}
        [:iconify-icon {:icon "lucide:chevron-right"}]]]
      [:td [:strong name]]
      [:td [:code slug]]
      [:td.numeric (ui/format-number tenant-count)]
      [:td.numeric (ui/format-number user-count)]
      [:td.numeric (ui/format-number document-count-30d)]
      [:td.numeric
       (if (pos? review-backlog)
         [:span.review-badge (ui/format-number review-backlog)]
         "0")]
      [:td (or (format-relative-timestamp last-activity) "-")]
      [:td.actions
       [:button.btn-icon {:type      "button"
                          :title     "Edit organization"
                          :hx-get    (str "/organizations/" id)
                          :hx-target "#modal"
                          :hx-swap   "innerHTML"}
        [:iconify-icon {:icon "lucide:pencil"}]]]]
     ;; Placeholder row for expansion content — swapped out by hx-get above.
     [:tr {:id (str "org-expansion-" id) :class "org-expansion-placeholder"}])))

Replace the existing organizations-table defn with a version that accepts the new column set:

(defn ^:private organizations-table
  "Render the top-level organizations table with expandable rows."
  [organizations]
  [:div.table-container
   [:table.data-table
    [:thead
     [:tr
      [:th.expand-col ""]
      [:th "Name"]
      [:th "Slug"]
      [:th.numeric "Tenants"]
      [:th.numeric "Users"]
      [:th.numeric "Docs (30d)"]
      [:th.numeric "Review"]
      [:th "Last activity"]
      [:th "Actions"]]]
    [:tbody#organizations-tbody
     (if (seq organizations)
       (for [organization organizations]
         (organization-row organization))
       [:tr
        [:td {:colspan 9}
         [:div.empty-state
          [:iconify-icon {:icon "lucide:building-2"}]
          [:p "No organizations found"]]]])]]])

In render-organizations-page, delete these bindings and section block:

;; DELETE:
tenants (queries/tenant-overview db-pool)
...
;; Tenants Section
(ui/section {:title    "Tenants"
             :subtitle "Companies with VAT/tax IDs - assigned to organizations"}
  [:div
   (create-tenant-form db-pool)
   [:div {:style "margin-top: 24px;"}
    (tenants-table tenants)]])

Keep the "Organizations" section + modal. The resulting render-organizations-page should look like:

(defn ^:private render-organizations-page
  "Renders the organizations management page."
  [_request db-pool]
  (let [organizations (queries/organization-overview db-pool)]
    [:div.tenants-dashboard
     [:div.page-header
      [:h1 "Organizations"]]
     (ui/section {:title    "Organizations"
                  :subtitle "Each organization groups tenants and members"}
       [:div
        (create-organization-form)
        [:div {:style "margin-top: 24px;"}
         (organizations-table organizations)]])
     ;; Modal container for edit dialogs
     [:div#modal]]))

Delete the tenants-table and tenant-row defns from organizations.clj. The nested row renderer lives inside the expansion endpoint (added in Task 5).

The handler previously returned a <tr> destined for #tenants-tbody, which we just deleted. Replace its success branch with an HX-Refresh so the form's caller (the modal added in Task 6) reloads the page:

(defn create-tenant!
  "POST /organizations/-/tenants - Create a new tenant."
  [{:keys [db-pool parameters]} respond _raise]
  (let [params (normalize-tenant-params (:form parameters))]
    (respond
     (let [{:keys [errors valid]} (validate-tenant-params db-pool params)]
       (if errors
         (ring.resp/ok (render-form-errors errors "tenant-form-errors"))
         (do
           (insert-tenant! db-pool valid)
           (-> (ring.resp/no-content)
               (assoc-in [:headers "HX-Refresh"] "true"))))))))

Between this task and Task 6, the tenant-create form is reachable only via the (now-unused) inline form code paths we've already deleted — there is no UI that POSTs to this endpoint until Task 6 lands. The endpoint still exists for safety.

Run: clj-kondo --lint src test dev Expected: clean. If any requires or private defns become unused, remove them.

Visit /organizations. Expected:

git add src/com/getorcha/admin/http/organizations.clj
git commit -m "refactor(admin): rework organizations table with chevron and activity columns"

Task 5: Implement the expansion endpoint and collapse behavior

Add GET /organizations/:id/expanded-tenants returning a single <tr> containing the nested tenant table. Clicking the chevron again should collapse — a second hx-get to /expanded-tenants/collapsed returns the original placeholder.

Files:

Add the following defn near the organization UI components:

(defn ^:private nested-tenant-row
  "Render one tenant row inside the expansion of its organization."
  [{:tenant/keys [id name company-country] :as tenant}]
  (let [document-count (or (:document-count tenant) 0)
        review-backlog (or (:review-backlog tenant) 0)
        source-count   (or (:source-count tenant) 0)
        active-sources (or (:active-sources tenant) 0)
        datev-active   (or (:datev-active tenant) false)
        last-activity  (:last-activity tenant)]
    [:tr {:class "nested-tenant-row"}
     [:td
      [:a {:href (str "/organizations/-/tenants/" id)}
       [:strong name]]]
     [:td (or company-country "-")]
     [:td.numeric (ui/format-number document-count)]
     [:td.numeric
      (if (pos? review-backlog)
        [:span.review-badge (ui/format-number review-backlog)]
        "0")]
     [:td.numeric (str active-sources "/" source-count)]
     [:td.numeric
      (if datev-active
        [:span {:title "DATEV integration active"} "✓"]
        [:span {:title "No DATEV integration"} "-"])]
     [:td (or (format-relative-timestamp last-activity) "-")]]))
(defn ^:private organization-expansion-tr
  "Render the single <tr> that replaces the expansion placeholder.
   Its chevron reverses: clicking it collapses the org."
  [organization-id tenants]
  [:tr {:id (str "org-expansion-" organization-id) :class "org-expansion"}
   [:td {:colspan 9}
    [:div.nested-tenants
     [:div.nested-tenants-header
      [:button.btn-icon.expand-chevron.expanded
       {:type      "button"
        :title     "Collapse"
        :hx-get    (str "/organizations/" organization-id "/expanded-tenants/collapsed")
        :hx-target (str "#org-expansion-" organization-id)
        :hx-swap   "outerHTML"}
       [:iconify-icon {:icon "lucide:chevron-down"}]]
      [:span.nested-tenants-title "Tenants"]]
     [:table.data-table.nested-table
      [:thead
       [:tr
        [:th "Name"]
        [:th "Country"]
        [:th.numeric "Docs"]
        [:th.numeric "Review"]
        [:th.numeric "Sources"]
        [:th.numeric "DATEV"]
        [:th "Last activity"]]]
      [:tbody
       (if (seq tenants)
         (for [tenant tenants]
           (nested-tenant-row tenant))
         [:tr [:td {:colspan 7}
               [:div.empty-state-inline "No tenants for this organization yet."]]])]]]]])
(defn ^:private organization-expansion-placeholder
  "Render the empty placeholder tr returned when collapsing."
  [organization-id]
  [:tr {:id (str "org-expansion-" organization-id) :class "org-expansion-placeholder"}])

Update organization-row (from Task 4, Step 2) so the placeholder it emits on the initial render also flips the chevron button to its .expanded state on expand. The cleanest way is to have the expand handler (next step) respond with both the expansion tr and an OOB swap replacing the chevron in the organization row. Revise organization-row's chevron button to include an explicit id:

[:button.btn-icon.expand-chevron {:id (str "chevron-" id) ...}
 [:iconify-icon {:icon "lucide:chevron-right"}]]
(defn expand-tenants
  "GET /organizations/:id/expanded-tenants - Returns the expansion tr
   with the nested tenants table. Also OOB-swaps the chevron icon."
  [{:keys [db-pool parameters]} respond _raise]
  (let [organization-id (get-in parameters [:path :id])
        tenants         (queries/tenants-by-organization db-pool organization-id)]
    (respond
     (ring.resp/ok
      (layout/partial-content
       (list
        (organization-expansion-tr organization-id tenants)
        ;; OOB-swap the chevron button to reverse the next click to collapse.
        [:button.btn-icon.expand-chevron.expanded
         {:id         (str "chevron-" organization-id)
          :hx-swap-oob "outerHTML"
          :type       "button"
          :title      "Collapse tenants"
          :hx-get     (str "/organizations/" organization-id "/expanded-tenants/collapsed")
          :hx-target  (str "#org-expansion-" organization-id)
          :hx-swap    "outerHTML"}
         [:iconify-icon {:icon "lucide:chevron-down"}]]))))))


(defn collapse-organization
  "GET /organizations/:id/expanded-tenants/collapsed - Returns the empty
   placeholder tr, restoring the collapsed state. Also OOB-swaps the
   chevron back to its initial orientation."
  [{:keys [parameters]} respond _raise]
  (let [organization-id (get-in parameters [:path :id])]
    (respond
     (ring.resp/ok
      (layout/partial-content
       (list
        (organization-expansion-placeholder organization-id)
        [:button.btn-icon.expand-chevron
         {:id         (str "chevron-" organization-id)
          :hx-swap-oob "outerHTML"
          :type       "button"
          :title      "Expand tenants"
          :hx-get     (str "/organizations/" organization-id "/expanded-tenants")
          :hx-target  (str "#org-expansion-" organization-id)
          :hx-swap    "outerHTML"}
         [:iconify-icon {:icon "lucide:chevron-right"}]]))))))

Use unambiguous literal paths (/-/expanded-tenants, not /:id/tenants) to avoid trie ambiguity with other /-/tenants… routes. The full subtree:

["/organizations"
 [""
  {:name ::index
   :get  {:handler #'index}
   :post {:parameters {:form organization-form-schema}
          :handler    #'create-organization!}}]
 ["/-/generate-slug"
  {:name       ::generate-slug
   :get        {:parameters {:query [:map [:name {:optional true} :string]]}
                :handler    #'generate-slug}}]
 ["/-/tenants"
  {:name ::create-tenant
   :post {:parameters {:form tenant-form-schema}
          :handler    #'create-tenant!}}]
 ;; NOTE: GET/PUT on /-/tenants/:id moves to admin/http/tenants.clj in Task 7.
 ;; We keep the POST for create above, nothing more on that path here.
 ["/:id"
  {:name ::organization
   :get  {:parameters {:path {:id :uuid}}
          :handler    #'show-organization}
   :put  {:parameters {:path {:id :uuid}
                       :form organization-form-schema}
          :handler    #'update-organization!}}]
 ["/:id/expanded-tenants"
  {:name ::expand-tenants
   :get  {:parameters {:path {:id :uuid}}
          :handler    #'expand-tenants}}]
 ["/:id/expanded-tenants/collapsed"
  {:name ::collapse-organization
   :get  {:parameters {:path {:id :uuid}}
          :handler    #'collapse-organization}}]]

Update the hx-get URLs in organization-row (Task 4 Step 2) and in the expansion/collapse responses (Steps 1, 2, 3, 4 of this task): every /organizations/<uuid>/tenants becomes /organizations/<uuid>/expanded-tenants, and every /organizations/<uuid>/tenants/collapse becomes /organizations/<uuid>/expanded-tenants/collapsed.

Run: clj-kondo --lint src test dev Expected: clean.

Visit /organizations. For an org with tenants:

git add src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): add HTMX expansion and collapse for organization rows"

Task 6: Keep the create tenant form accessible from the top-level page

Pre-refactor, the top-level page rendered a create-tenant form in its own section. We removed the section in Task 4. The spec says "create-tenant shortcut" belongs in the Actions column of the organization row. Add a small "+ Tenant" button on each org row that opens a create-tenant modal scoped to that org.

Files:

(defn ^:private create-tenant-modal
  "Modal dialog with a create-tenant form, scoped to one organization."
  [{:organization/keys [id name]}]
  [:div.modal-backdrop {:onclick "if(event.target === this) this.remove()"}
   [:div.modal-content
    [:div.modal-header
     [:h3 (str "Create Tenant in " name)]
     [:button.modal-close {:type "button"
                           :onclick "this.closest('.modal-backdrop').remove()"}
      [:iconify-icon {:icon "lucide:x"}]]]
    [:form.tenant-form
     {:hx-post             "/organizations/-/tenants"
      :hx-target           "#modal"
      :hx-swap             "innerHTML"
      :hx-on--after-request
      "if(event.detail.successful) { this.closest('.modal-backdrop').remove(); window.location.reload(); }"}
     [:div.modal-body
      [:div#tenant-form-errors]
      [:input {:type "hidden" :name "organization-id" :value (str id)}]
      [:div.form-row
       [:div.form-group
        [:label {:for "tenant-name"} "Name *"]
        [:input.form-input {:type     "text"
                            :id       "tenant-name"
                            :name     "name"
                            :required true}]]]
      [:div.form-row
       [:div.form-group
        [:label {:for "tenant-company-country"} "Country"]
        (country-select {:id    "tenant-company-country"
                         :name  "company-country"
                         :value nil})]]]
     [:div.modal-footer
      [:button.btn {:type "button"
                    :onclick "this.closest('.modal-backdrop').remove()"}
       "Cancel"]
      [:button.btn.btn-primary {:type "submit"}
       "Create Tenant"]]]]])


(defn new-tenant-form
  "GET /organizations/:id/new-tenant - Open the create-tenant modal."
  [{:keys [db-pool parameters]} respond _raise]
  (let [organization-id (get-in parameters [:path :id])]
    (respond
     (if-let [organization (get-organization db-pool organization-id)]
       (ring.resp/ok
        (layout/partial-content (create-tenant-modal organization)))
       (ring.resp/not-found
        (layout/partial-content
         [:div.alert.alert-danger "Organization not found."]))))))

Inside the /organizations subtree in routes, add:

["/:id/new-tenant"
 {:name ::new-tenant
  :get  {:parameters {:path {:id :uuid}}
         :handler    #'new-tenant-form}}]

Inside the :td.actions cell of organization-row, before the edit button:

[:button.btn-icon {:type      "button"
                   :title     "Create tenant in this organization"
                   :hx-get    (str "/organizations/" id "/new-tenant")
                   :hx-target "#modal"
                   :hx-swap   "innerHTML"}
 [:iconify-icon {:icon "lucide:plus"}]]

Run: clj-kondo --lint src test dev Expected: clean.

git add src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): add scoped create-tenant modal from organization row actions"

Task 7: Scaffold admin/http/tenants.clj with detail-page shell and route

Before filling in sections, stand up the namespace, register the route, and render an empty shell so the links from the expansion table resolve.

Files:

Create src/com/getorcha/admin/http/tenants.clj with:

(ns com.getorcha.admin.http.tenants
  "Dedicated per-tenant detail page.

   Full-page view at /organizations/-/tenants/:id with stacked sections
   for every configuration area and a stats panel. Most sections are
   read-only summaries that link out to the canonical editor; only
   identity and API keys are editable inline."
  (:require [com.getorcha.admin.db.queries :as queries]
            [com.getorcha.admin.ui.components :as ui]
            [com.getorcha.admin.ui.layout :as layout]
            [next.jdbc :as jdbc]
            [ring.util.http-response :as ring.resp]))


;; ============================================================================
;; Tenant lookup
;; ============================================================================

(defn ^:private get-tenant
  "Fetch tenant with enough identity fields for the header."
  [db-pool tenant-id]
  (jdbc/execute-one!
   db-pool
   ["SELECT tenant.id, tenant.name, tenant.organization_id,
            tenant.company_address, tenant.company_vat_id,
            tenant.company_tax_id, tenant.company_country,
            organization.name as organization_name,
            organization.slug as organization_slug
     FROM tenant
     JOIN organization ON tenant.organization_id = organization.id
     WHERE tenant.id = ?"
    tenant-id]))


;; ============================================================================
;; Left-rail anchor navigation
;; ============================================================================

(def ^:private section-anchors
  "Ordered list of (id, label) pairs matching the sections rendered below."
  [["identity"      "Identity"]
   ["sources"       "Ingestion Sources"]
   ["datev"         "ERP Integration"]
   ["oauth"         "OAuth Integrations"]
   ["master-data"   "Master Data"]
   ["prompts"       "Prompt Customizations"]
   ["file-store"    "File Store"]
   ["notifications" "Notifications"]
   ["booking"       "Booking History"]
   ["api-keys"      "API Keys"]
   ["qa"            "QA Dataset"]
   ["stats"         "Stats & Cost"]])


(defn ^:private anchor-nav
  []
  [:nav.tenant-detail-nav
   [:ul
    (for [[anchor label] section-anchors]
      [:li {:key anchor}
       [:a {:href (str "#" anchor)} label]])]])


;; ============================================================================
;; Header
;; ============================================================================

(defn ^:private header
  [{:tenant/keys [id] :as tenant}]
  (let [tenant-name       (:tenant/name tenant)
        organization-name (:organization/organization-name tenant)
        organization-slug (:organization/organization-slug tenant)]
    [:div.page-header.tenant-detail-header
     [:div.breadcrumb
      [:a {:href "/organizations"} "Organizations"]
      [:span " → "]
      [:a {:href (str "/organizations?highlight=" organization-slug)}
       organization-name]
      [:span " → "]
      [:span#breadcrumb-tenant-name tenant-name]]
     [:h1#tenant-detail-title tenant-name]
     (comment "Quick-stats strip is added in Task 9.")
     [:div#tenant-quick-stats]
     [:div.tenant-actions
      [:a.btn {:href (str "/costs?tenant=" id)} "Open cost dashboard"]]]))


;; ============================================================================
;; Section rendering (stubs — filled in by later tasks)
;; ============================================================================

(defn ^:private section-placeholder
  "Used by tasks that have not yet landed their section content."
  [anchor title]
  [:section {:id anchor}
   [:h2 title]
   [:p.empty-state "(coming soon)"]])


(defn ^:private render-detail-page
  [_request _db-pool tenant]
  [:div.tenant-detail-page
   (header tenant)
   [:div.tenant-detail-body
    (anchor-nav)
    [:div.tenant-detail-sections
     (for [[anchor label] section-anchors]
       ^{:key anchor}
       (section-placeholder anchor label))]]])


;; ============================================================================
;; Route Handler
;; ============================================================================

(defn index
  "GET /organizations/-/tenants/:id - Tenant detail page."
  [{:keys [db-pool parameters] :as request} respond _raise]
  (let [tenant-id (get-in parameters [:path :id])]
    (respond
     (if-let [tenant (get-tenant db-pool tenant-id)]
       (ring.resp/ok
        (layout/render
         request
         {:title        (str "Tenant: " (:tenant/name tenant))
          :current-path "/organizations"}
         (render-detail-page request db-pool tenant)))
       (ring.resp/not-found
        (layout/partial-content
         [:div.alert.alert-danger "Tenant not found."]))))))


;; ============================================================================
;; Routes
;; ============================================================================

(defn routes
  [_config]
  [["/organizations/-/tenants/:id"
    {:name ::index
     :get  {:parameters {:path {:id :uuid}}
            :handler    #'index}}]])

Note: the existing GET and PUT on /organizations/-/tenants/:id in admin/http/organizations.clj must move out entirely — Reitit treats sibling subtrees as a single trie and will reject duplicate paths across namespaces. The new detail-page GET lives in tenants.clj; the PUT (never actually called from the new UI, which uses field-level PUT in Task 8) is removed.

In admin/http/organizations.clj routes, delete the entire ["/-/tenants/:id" ...] subtree (both :get and :put methods). The modal-returning show-tenant and the row-returning update-tenant! handlers become unused — also delete the defns and their private helpers (get-tenant, edit-tenant-modal) if nothing else in the file references them. clj-kondo will flag any orphans in Step 4.

Since Task 4 already deleted tenant-row (which was the only renderer emitting a link to the old GET modal), no UI still depends on those routes.

In src/com/getorcha/admin/http.clj:

[com.getorcha.admin.http.tenants :as admin.http.tenants]

(alphabetical placement: after ses-sources, before users)

(admin.http.tenants/routes config)

Run: clj-kondo --lint src test dev Expected: clean.

If the running system has the old router cached, (integrant.repl/reset) first — Reitit builds the trie at handler-creation time.

Click a tenant link in an expanded org row. Expected:

Reload the page — no network errors. No Reitit route-conflict on server start (if there is one, check that Task 7 Step 2's deletion was complete).

git add src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/http.clj src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): add tenant detail page shell with anchor nav"

Task 8: Identity section with inline PUT-per-field + OOB breadcrumb updates

The first fillable section. Each identity field (name, company-address, VAT ID, tax ID, country) is a span; clicking "edit" swaps to a form; submitting PUTs the single field and swaps back to the span. Changing name OOB-updates the breadcrumb crumb and the H1 title.

Files:

In src/com/getorcha/admin/ui/components.clj, below export-button, add:

(def country-options
  "Common European countries for company registration."
  [["AT" "Austria"] ["BE" "Belgium"] ["CH" "Switzerland"] ["DE" "Germany"]
   ["DK" "Denmark"] ["ES" "Spain"] ["FR" "France"] ["GB" "United Kingdom"]
   ["IT" "Italy"] ["NL" "Netherlands"] ["PL" "Poland"] ["US" "United States"]])


(defn country-select
  "Render a country select dropdown."
  [{:keys [id name value]}]
  [:select.form-select {:id id :name name}
   [:option {:value ""} "Select country..."]
   (for [[code label] country-options]
     [:option {:value code :selected (= value code)} label])])

In src/com/getorcha/admin/http/organizations.clj:

In admin/http/tenants.clj, add a helper field-span that renders an editable value:

(defn ^:private field-span
  "Render a single inline-editable identity field as a labeled span.
   Clicking the edit button swaps the whole row to an input form."
  [{:keys [tenant-id field label value type]}]
  [:div.identity-field {:id (str "identity-field-" (name field))}
   [:div.identity-label label]
   [:div.identity-value-row
    [:span.identity-value (or value [:em.empty "—"])]
    [:button.btn-icon.edit-btn
     {:type      "button"
      :title     (str "Edit " label)
      :hx-get    (str "/organizations/-/tenants/" tenant-id
                      "/fields/" (name field) "/edit")
      :hx-target (str "#identity-field-" (name field))
      :hx-swap   "outerHTML"
      :data-type (or type "text")}
     [:iconify-icon {:icon "lucide:pencil"}]]]])


(defn ^:private identity-section
  [tenant]
  ;; Explicit lookup instead of destructuring `name` (which would shadow
  ;; clojure.core/name used elsewhere in this ns).
  (let [id               (:tenant/id tenant)
        tenant-name      (:tenant/name tenant)
        company-address  (:tenant/company-address tenant)
        company-vat-id   (:tenant/company-vat-id tenant)
        company-tax-id   (:tenant/company-tax-id tenant)
        company-country  (:tenant/company-country tenant)]
    [:section#identity
     [:h2 "Identity & Company Info"]
     [:div.identity-grid
      (field-span {:tenant-id id :field :name            :label "Name"
                   :value tenant-name})
      (field-span {:tenant-id id :field :company-address :label "Company Address"
                   :value company-address :type "multiline"})
      (field-span {:tenant-id id :field :company-vat-id  :label "VAT ID"
                   :value company-vat-id})
      (field-span {:tenant-id id :field :company-tax-id  :label "Tax ID"
                   :value company-tax-id})
      (field-span {:tenant-id id :field :company-country :label "Country"
                   :value company-country :type "country"})]]))

Restructure render-detail-page to use an explicit per-section function map, so later tasks swap entries instead of chasing a drop N index:

(defn ^:private render-detail-page
  [_request db-pool tenant]
  (let [tenant-id (:tenant/id tenant)
        sections  {"identity"      (fn [_ _] (identity-section tenant))
                   ;; Tasks 10-20 replace each of these stubs with the real
                   ;; section renderer. Keep the anchor → fn shape identical.
                   "sources"       (fn [_ _] (section-placeholder "sources"       "Ingestion Sources"))
                   "datev"         (fn [_ _] (section-placeholder "datev"         "ERP Integration"))
                   "oauth"         (fn [_ _] (section-placeholder "oauth"         "OAuth Integrations"))
                   "master-data"   (fn [_ _] (section-placeholder "master-data"   "Master Data"))
                   "prompts"       (fn [_ _] (section-placeholder "prompts"       "Prompt Customizations"))
                   "file-store"    (fn [_ _] (section-placeholder "file-store"    "File Store"))
                   "notifications" (fn [_ _] (section-placeholder "notifications" "Notifications"))
                   "booking"       (fn [_ _] (section-placeholder "booking"       "Booking History"))
                   "api-keys"      (fn [_ _] (section-placeholder "api-keys"      "API Keys"))
                   "qa"            (fn [_ _] (section-placeholder "qa"            "QA Dataset"))
                   "stats"         (fn [_ _] (section-placeholder "stats"         "Stats & Cost"))}]
    [:div.tenant-detail-page
     (header db-pool tenant)
     [:div.tenant-detail-body
      (anchor-nav)
      [:div.tenant-detail-sections
       (for [[anchor _label] section-anchors
             :let [render (get sections anchor)]]
         ^{:key anchor}
         (render db-pool tenant-id))]]]))

In Tasks 10-20, the executor replaces one map entry (e.g. "sources" sources-section) rather than touching a positional loop. Task 21 removes the section-placeholder helper once every slot is filled.

(def ^:private editable-fields
  {"name"            {:label "Name"            :type :text      :column :name}
   "company-address" {:label "Company Address" :type :multiline :column :company-address}
   "company-vat-id"  {:label "VAT ID"          :type :text      :column :company-vat-id}
   "company-tax-id"  {:label "Tax ID"          :type :text      :column :company-tax-id}
   "company-country" {:label "Country"         :type :country   :column :company-country}})


(defn ^:private input-for-type
  [{:keys [type name-attr value]}]
  (case type
    :multiline [:textarea.form-input {:name name-attr :rows 3} (or value "")]
    :country   (ui/country-select {:id name-attr :name name-attr :value value})
    [:input.form-input {:type "text" :name name-attr :value (or value "")}]))


(defn ^:private field-edit-form
  "Inline form that replaces the span. Submitting PUTs the field value."
  [tenant-id field {:keys [label type]} value]
  [:div.identity-field.editing
   {:id (str "identity-field-" field)}
   [:div.identity-label label]
   [:form
    {:hx-put    (str "/organizations/-/tenants/" tenant-id "/fields/" field)
     :hx-target (str "#identity-field-" field)
     :hx-swap   "outerHTML"
     :hx-trigger "submit, focusout from:find input delay:200ms"}
    (input-for-type {:type type :name-attr "value" :value value})
    [:div.form-actions
     [:button.btn.btn-primary {:type "submit"} "Save"]
     [:button.btn {:type  "button"
                   :hx-get (str "/organizations/-/tenants/" tenant-id
                                "/fields/" field)
                   :hx-target (str "#identity-field-" field)
                   :hx-swap  "outerHTML"}
      "Cancel"]]]])


(defn edit-field
  "GET /organizations/-/tenants/:id/fields/:field/edit - Return the inline form."
  [{:keys [db-pool parameters]} respond _raise]
  (let [tenant-id (get-in parameters [:path :id])
        field     (get-in parameters [:path :field])
        meta      (get editable-fields field)]
    (respond
     (cond
       (nil? meta)
       (ring.resp/bad-request "Unknown field.")

       :else
       (let [tenant  (get-tenant db-pool tenant-id)
             value   (get tenant (keyword "tenant" field))]
         (ring.resp/ok
          (layout/partial-content
           (field-edit-form tenant-id field meta value))))))))


(defn get-field
  "GET /organizations/-/tenants/:id/fields/:field - Return the display span
   (used by the Cancel button)."
  [{:keys [db-pool parameters]} respond _raise]
  (let [tenant-id (get-in parameters [:path :id])
        field     (get-in parameters [:path :field])
        meta      (get editable-fields field)
        tenant    (get-tenant db-pool tenant-id)]
    (respond
     (if (and meta tenant)
       (ring.resp/ok
        (layout/partial-content
         (field-span {:tenant-id tenant-id
                      :field     (keyword field)
                      :label     (:label meta)
                      :value     (get tenant (keyword "tenant" field))
                      :type      (name (:type meta))})))
       (ring.resp/not-found "")))))


(defn update-field!
  "PUT /organizations/-/tenants/:id/fields/:field - Save one identity field."
  [{:keys [db-pool parameters]} respond _raise]
  (let [tenant-id (get-in parameters [:path :id])
        field     (get-in parameters [:path :field])
        raw-value (get-in parameters [:form :value])
        new-value (when-not (clojure.string/blank? raw-value)
                    (clojure.string/trim raw-value))
        meta      (get editable-fields field)]
    (respond
     (cond
       (nil? meta)
       (ring.resp/bad-request "Unknown field.")

       (and (= field "name") (nil? new-value))
       (ring.resp/ok
        (layout/partial-content
         [:div.alert.alert-danger "Name is required."]))

       :else
       (do
         ;; Parameterized column update — whitelist via editable-fields.
         (jdbc/execute-one!
          db-pool
          [(str "UPDATE tenant SET "
                (clojure.string/replace field "-" "_")
                " = ?, updated_at = now() WHERE id = ?")
           new-value tenant-id])
         (let [tenant (get-tenant db-pool tenant-id)
               field-span-hiccup
               (field-span {:tenant-id tenant-id
                            :field     (keyword field)
                            :label     (:label meta)
                            :value     (get tenant (keyword "tenant" field))
                            :type      (name (:type meta))})]
           (ring.resp/ok
            (layout/partial-content
             (if (= field "name")
               (list
                field-span-hiccup
                [:span#breadcrumb-tenant-name {:hx-swap-oob "outerHTML"}
                 (:tenant/name tenant)]
                [:h1#tenant-detail-title {:hx-swap-oob "outerHTML"}
                 (:tenant/name tenant)])
               field-span-hiccup)))))))))

Add clojure.string to the ns require:

(:require [clojure.string :as string]
          ...)

Then use string/blank? / string/trim / string/replace instead of the fully-qualified names in the handler above (keep naming clean).

In tenants.clj routes:

(defn routes
  [_config]
  [["/organizations/-/tenants/:id"
    [""
     {:name ::index
      :get  {:parameters {:path {:id :uuid}}
             :handler    #'index}}]
    ["/fields/:field"
     {:name ::field
      :get {:parameters {:path {:id :uuid :field :string}}
            :handler    #'get-field}
      :put {:parameters {:path {:id :uuid :field :string}
                         :form [:map [:value {:optional true} :string]]}
            :handler    #'update-field!}}]
    ["/fields/:field/edit"
     {:name ::edit-field
      :get {:parameters {:path {:id :uuid :field :string}}
            :handler    #'edit-field}}]]])

Run: clj-kondo --lint src test dev Expected: clean.

git add src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/ui/components.clj src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): identity section with inline PUT-per-field and OOB updates"

Task 9: Quick-stats strip in the header

Fills in the #tenant-quick-stats placeholder with docs, review backlog, active sources, DATEV status, token spend (30d), last activity.

Files:

In admin/db/queries.clj, append:

(defn tenant-detail-header-stats
  "Header quick-stats for a tenant: doc count, review backlog, active sources,
   DATEV state, 30d token spend, last activity."
  [db-pool tenant-id]
  (jdbc/execute-one!
   db-pool
   ["SELECT
       (SELECT COUNT(*) FROM document
         WHERE document.tenant_id = tenant.id) as document_count,
       (SELECT COUNT(*) FROM document
         WHERE document.tenant_id = tenant.id
           AND document.needs_human_review = true) as review_backlog,
       (SELECT COUNT(*) FROM ap_doc_source
         WHERE ap_doc_source.tenant_id = tenant.id
           AND ap_doc_source.is_active = true) as active_sources,
       (SELECT COUNT(*) FROM ap_doc_source
         WHERE ap_doc_source.tenant_id = tenant.id) as total_sources,
       EXISTS (SELECT 1 FROM tenant_datev_integration
                WHERE tenant_datev_integration.tenant_id = tenant.id
                  AND tenant_datev_integration.is_active = true) as datev_active,
       (SELECT COALESCE(SUM(ap_ingestion.extraction_input_tokens), 0)
              + COALESCE(SUM(ap_ingestion.extraction_output_tokens), 0)
              + COALESCE(SUM(ap_ingestion.transcription_input_tokens), 0)
              + COALESCE(SUM(ap_ingestion.transcription_output_tokens), 0)
        FROM ap_ingestion
        JOIN document ON document.id = ap_ingestion.document_id
        WHERE document.tenant_id = tenant.id
          AND ap_ingestion.created_at > NOW() - INTERVAL '30 days') as tokens_30d,
       (SELECT MAX(document.updated_at) FROM document
         WHERE document.tenant_id = tenant.id) as last_activity
     FROM tenant tenant
     WHERE tenant.id = ?"
    tenant-id]))

Add a renderer:

(defn ^:private quick-stats [stats]
  (let [active        (or (:active-sources stats) 0)
        total         (or (:total-sources stats) 0)
        review        (or (:review-backlog stats) 0)
        datev         (:datev-active stats)
        tokens-30d    (or (:tokens-30d stats) 0)
        last-activity (:last-activity stats)]
    (ui/stat-grid
     [{:icon "lucide:file-text"  :label "Documents"
       :value (ui/format-number (or (:document-count stats) 0))}
      {:icon "lucide:alert-triangle" :label "Review backlog"
       :value (ui/format-number review)
       :variant (when (pos? review) :warning)}
      {:icon "lucide:mail" :label "Active sources"
       :value (str (ui/format-number active) " / " (ui/format-number total))}
      {:icon "lucide:link" :label "DATEV"
       :value (if datev "Connected" "Disconnected")
       :variant (if datev :success :warning)}
      {:icon "lucide:zap" :label "Tokens (30d)"
       :value (ui/format-number tokens-30d)}
      {:icon "lucide:clock" :label "Last activity"
       :value (or (format-relative-timestamp last-activity) "—")}])))

Note: format-relative-timestamp does not exist in tenants.clj. Add the same helper used in organizations.clj (copy the relative-formatter def + format-relative-timestamp defn + the three :imports into the ns block of tenants.clj). Or, cleaner: lift it to admin.ui.components and require from both namespaces. Lift it:

Replace:

(comment "Quick-stats strip is added in Task 9.")
[:div#tenant-quick-stats]

with a call that fetches stats. The header function needs db-pool. Propagate it:

(defn ^:private header
  [db-pool tenant]
  (let [stats (queries/tenant-detail-header-stats db-pool (:tenant/id tenant))
        organization-name (:organization/organization-name tenant)
        organization-slug (:organization/organization-slug tenant)
        tenant-name       (:tenant/name tenant)]
    [:div.page-header.tenant-detail-header
     [:div.breadcrumb
      [:a {:href "/organizations"} "Organizations"]
      [:span " → "]
      [:a {:href (str "/organizations?highlight=" organization-slug)}
       organization-name]
      [:span " → "]
      [:span#breadcrumb-tenant-name tenant-name]]
     [:h1#tenant-detail-title tenant-name]
     [:div#tenant-quick-stats (quick-stats stats)]
     [:div.tenant-actions
      [:a.btn {:href (str "/costs?tenant=" (:tenant/id tenant))}
       "Open cost dashboard"]]]))

Update render-detail-page to pass db-pool:

(defn ^:private render-detail-page
  [_request db-pool tenant]
  [:div.tenant-detail-page
   (header db-pool tenant)
   ...])

Run: clj-kondo --lint src test dev Expected: clean.

Reload a tenant detail page. Six stat cards appear in the header. Values match SELECT queries run directly against the DB.

git add src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/ui/components.clj src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): quick-stats strip in tenant detail header"

Task 10: Ingestion Sources section (§2)

Files:

(defn tenant-ingestion-sources
  "Active and inactive ap_doc_source rows for a tenant.
   Left-joins the email subtype so either email or SES sources render."
  [db-pool tenant-id]
  (jdbc/execute!
   db-pool
   ["SELECT
       ap_doc_source.id,
       ap_doc_source.type,
       ap_doc_source.is_active,
       ap_doc_source.documents_received,
       ap_doc_source.last_document_at,
       ap_doc_source_email.email_address,
       ap_doc_source_email.provider,
       ap_doc_source_email.connection_status,
       ap_doc_source_email.last_successful_sync_at
     FROM ap_doc_source
     LEFT JOIN ap_doc_source_email
            ON ap_doc_source_email.doc_source_id = ap_doc_source.id
     WHERE ap_doc_source.tenant_id = ?
     ORDER BY ap_doc_source.is_active DESC, ap_doc_source.created_at DESC"
    tenant-id]))

In tenants.clj:

(defn ^:private sources-section [db-pool tenant-id]
  (let [rows (queries/tenant-ingestion-sources db-pool tenant-id)]
    [:section#sources
     [:h2 "Ingestion Sources"]
     [:p.section-hint
      [:a {:href "/settings/sources"} "Manage sources in the settings page"]]
     (ui/data-table
      {:columns [{:key :address       :label "Address"}
                 {:key :provider      :label "Provider"}
                 {:key :status        :label "Status"}
                 {:key :last-sync     :label "Last sync"}
                 {:key :docs-received :label "Docs" :class "numeric"}
                 {:key :active?       :label "Active"}]
       :empty-message "No ingestion sources configured."
       :rows
       (for [row rows]
         {:address       (or (:ap-doc-source-email/email-address row)
                             (str (name (:ap-doc-source/type row)) " source"))
          :provider      (some-> (:ap-doc-source-email/provider row) name)
          :status        (some-> (:ap-doc-source-email/connection-status row) name)
          :last-sync     (or (ui/format-relative-timestamp
                              (:ap-doc-source-email/last-successful-sync-at row))
                             "—")
          :docs-received (ui/format-number
                          (or (:ap-doc-source/documents-received row) 0))
          :active?       (if (:ap-doc-source/is-active row) "Yes" "No")})})]))

Replace the "sources" entry in the sections map inside render-detail-page with sources-section:

"sources" sources-section

(i.e. remove the (fn [_ _] (section-placeholder "sources" "Ingestion Sources")) wrapper and hand over the real renderer, whose arity matches [db-pool tenant-id].)

clj-kondo --lint src test dev

Browse a tenant with sources — table shows their data; tenant with no sources — empty-state message.

git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): ingestion sources section on tenant detail page"

Task 11: ERP Integration (DATEV) section (§3)

Files:

First, confirm the column connected_by_user_id exists on tenant_datev_integration post-SP1:

psql -h localhost -U postgres -d orcha -c "\d tenant_datev_integration"

Expected: the column is present (the SP1 rename migration preserves a *_connected_by_user_id_fkey constraint at line 236). If absent, remove the JOIN and render "—" for "Connected by".

(defn tenant-datev-integration
  "DATEV integration row for a tenant, or nil.
   Natural column selection (no aliases) so keys come back
   table-qualified (e.g. :identity/email)."
  [db-pool tenant-id]
  (jdbc/execute-one!
   db-pool
   ["SELECT
       tenant_datev_integration.is_active,
       tenant_datev_integration.credentials_expires_at,
       tenant_datev_integration.metadata,
       tenant_datev_integration.connected_by_user_id,
       identity.email,
       identity.display_name
     FROM tenant_datev_integration
     LEFT JOIN identity
            ON identity.id = tenant_datev_integration.connected_by_user_id
     WHERE tenant_datev_integration.tenant_id = ?
       AND tenant_datev_integration.integration_type = 'datev'"
    tenant-id]))
(defn ^:private datev-section [db-pool tenant-id]
  (let [row (queries/tenant-datev-integration db-pool tenant-id)]
    [:section#datev
     [:h2 "ERP Integration (DATEV)"]
     [:p.section-hint
      [:a {:href "/settings/integrations"} "Connect or reconnect in settings"]]
     (if row
       [:dl.tenant-section-dl
        [:dt "Status"]
        [:dd (if (:tenant-datev-integration/is-active row) "Active" "Inactive")]
        [:dt "Company name"]
        ;; JSONB metadata is auto-decoded to a Clojure map with keyword keys
        ;; — see plan header "Infrastructure facts".
        [:dd (or (get-in row [:tenant-datev-integration/metadata :company-name])
                 "—")]
        [:dt "Connected by"]
        [:dd (or (:identity/display-name row)
                 (:identity/email row)
                 "—")]
        [:dt "Credentials expire"]
        [:dd (or (ui/format-relative-timestamp
                  (:tenant-datev-integration/credentials-expires-at row))
                 "—")]]
       [:p.empty-state "No DATEV integration configured."])]))

Replace the "datev" entry in the sections map: "datev" datev-section.

clj-kondo --lint src test dev
git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): DATEV integration summary on tenant detail page"

Task 12: OAuth Integrations section (§4)

Post-SP1 table: tenant_oauth_integration (confirmed via resources/migrations/20260316093116-rename-integration-add-oauth.up.sql + 20260424093839-rename-legal-entity-to-tenant.up.sql:142). Columns used here:

Files:

grep -n "folder\|:config" src/com/getorcha/app/http/settings/google_drive.clj | head -20

Identify which JSONB keys the Google Drive integration writes. Use those exact keys in the renderer. Keys are keywords post-decode (see the infrastructure notes at the top of this plan).

(defn tenant-oauth-integrations
  "OAuth integrations for a tenant (currently only Google Drive).
   Joins identity for the connector name; selects JSONB config so the
   view can pull folder-name / folder-id."
  [db-pool tenant-id]
  (jdbc/execute!
   db-pool
   ["SELECT
       tenant_oauth_integration.id,
       tenant_oauth_integration.integration_type,
       tenant_oauth_integration.is_active,
       tenant_oauth_integration.refresh_token_expires_at,
       tenant_oauth_integration.config,
       tenant_oauth_integration.created_at,
       identity.email,
       identity.display_name
     FROM tenant_oauth_integration
     LEFT JOIN identity
            ON identity.id = tenant_oauth_integration.connected_by_user_id
     WHERE tenant_oauth_integration.tenant_id = ?
     ORDER BY tenant_oauth_integration.integration_type"
    tenant-id]))
(defn ^:private oauth-section [db-pool tenant-id]
  (let [rows (queries/tenant-oauth-integrations db-pool tenant-id)]
    [:section#oauth
     [:h2 "OAuth Integrations"]
     [:p.section-hint
      [:a {:href "/settings/google-drive"} "Configure Google Drive in settings"]]
     (if (seq rows)
       (ui/data-table
        {:columns [{:key :provider     :label "Provider"}
                   {:key :active?      :label "Active"}
                   {:key :folder       :label "Folder"}
                   {:key :connected-by :label "Connected by"}
                   {:key :expires      :label "Token expires"}
                   {:key :connected-at :label "Connected"}]
         :rows
         (for [row rows]
           ;; JSONB config is auto-decoded to a Clojure map with keyword keys
           ;; (see plan header "Infrastructure facts"). Use keyword access.
           (let [config (:tenant-oauth-integration/config row)]
             {:provider     (some-> (:tenant-oauth-integration/integration-type row) name)
              :active?      (if (:tenant-oauth-integration/is-active row) "Yes" "No")
              :folder       (or (:folder-name config)
                                (:folder-id config)
                                "—")
              :connected-by (or (:identity/display-name row)
                                (:identity/email row)
                                "—")
              :expires      (or (ui/format-relative-timestamp
                                 (:tenant-oauth-integration/refresh-token-expires-at row))
                                "—")
              :connected-at (or (ui/format-relative-timestamp
                                 (:tenant-oauth-integration/created-at row))
                                "—")}))})
       [:p.empty-state "No OAuth integrations configured."])]))
clj-kondo --lint src test dev
git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): OAuth integrations section on tenant detail page"

Task 13: Master Data section (§5)

Three subsections: GL accounts, cost centers, business partners — each a row-count + active flag + last-updated + link to /settings/data.

Files:

(defn tenant-master-data-summary
  "Row counts and last-updated for GL accounts, cost centers, and
   business partners for a tenant."
  [db-pool tenant-id]
  (jdbc/execute-one!
   db-pool
   ["SELECT
       (SELECT COUNT(*) FROM gl_accounts_dataset
         WHERE gl_accounts_dataset.tenant_id = ?) as gl_account_datasets,
       (SELECT COALESCE(SUM(row_count), 0) FROM gl_accounts_dataset
         WHERE gl_accounts_dataset.tenant_id = ?) as gl_account_rows,
       (SELECT MAX(updated_at) FROM gl_accounts_dataset
         WHERE gl_accounts_dataset.tenant_id = ?) as gl_accounts_updated_at,
       (SELECT COUNT(*) FROM cost_center_dataset
         WHERE cost_center_dataset.tenant_id = ?) as cost_center_datasets,
       (SELECT COALESCE(SUM(row_count), 0) FROM cost_center_dataset
         WHERE cost_center_dataset.tenant_id = ?) as cost_center_rows,
       (SELECT MAX(updated_at) FROM cost_center_dataset
         WHERE cost_center_dataset.tenant_id = ?) as cost_centers_updated_at,
       (SELECT COUNT(*) FROM business_partner_dataset
         WHERE business_partner_dataset.tenant_id = ?) as business_partner_datasets,
       (SELECT COALESCE(SUM(row_count), 0) FROM business_partner_dataset
         WHERE business_partner_dataset.tenant_id = ?) as business_partner_rows,
       (SELECT MAX(updated_at) FROM business_partner_dataset
         WHERE business_partner_dataset.tenant_id = ?) as business_partners_updated_at"
    tenant-id tenant-id tenant-id
    tenant-id tenant-id tenant-id
    tenant-id tenant-id tenant-id]))

Confirm the table name is gl_accounts_dataset (plural-accounts, singular-dataset). Grep to confirm:

grep -rn "gl_accounts_dataset\|gl_account_dataset" src/com/getorcha 2>/dev/null | head
(defn ^:private master-data-section [db-pool tenant-id]
  (let [stats (queries/tenant-master-data-summary db-pool tenant-id)]
    [:section#master-data
     [:h2 "Master Data"]
     [:p.section-hint
      [:a {:href "/settings/data"} "Manage master data in settings"]]
     (ui/data-table
      {:columns [{:key :kind         :label "Kind"}
                 {:key :datasets     :label "Datasets" :class "numeric"}
                 {:key :rows         :label "Rows"     :class "numeric"}
                 {:key :last-updated :label "Updated"}]
       :rows
       [{:kind "GL Accounts"
         :datasets     (ui/format-number (or (:gl-account-datasets stats) 0))
         :rows         (ui/format-number (or (:gl-account-rows stats) 0))
         :last-updated (or (ui/format-relative-timestamp
                            (:gl-accounts-updated-at stats))
                           "—")}
        {:kind "Cost Centers"
         :datasets     (ui/format-number (or (:cost-center-datasets stats) 0))
         :rows         (ui/format-number (or (:cost-center-rows stats) 0))
         :last-updated (or (ui/format-relative-timestamp
                            (:cost-centers-updated-at stats))
                           "—")}
        {:kind "Business Partners"
         :datasets     (ui/format-number
                        (or (:business-partner-datasets stats) 0))
         :rows         (ui/format-number
                        (or (:business-partner-rows stats) 0))
         :last-updated (or (ui/format-relative-timestamp
                            (:business-partners-updated-at stats))
                           "—")}]})]))
git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): master data summary section on tenant detail page"

Task 14: Prompt Customizations section (§6)

Lists all 8 prompt keys with current additions; each is a link to the existing prompts editor under /organizations/-/tenants/:id/prompts?key=....

Files:

(defn tenant-prompt-customizations
  "Latest customization per prompt key for a tenant.
   Returns rows with :prompt-key and :additions; callers merge with the
   full known-prompt-key list to also surface keys with no customization."
  [db-pool tenant-id]
  (sql/execute!
   db-pool
   {:select-distinct-on [[:tenant-prompt-customization.prompt-key]
                         :tenant-prompt-customization.prompt-key
                         :tenant-prompt-customization.additions
                         :tenant-prompt-customization.created-at]
    :from     [:tenant-prompt-customization]
    :where    [:= :tenant-prompt-customization.tenant-id tenant-id]
    :order-by [[:tenant-prompt-customization.prompt-key :asc]
               [:tenant-prompt-customization.created-at :desc]]}))
(def ^:private known-prompt-keys
  [["extraction"                    "Extraction"]
   ["vision"                        "Vision"]
   ["accounts-match"                "Accounts Match"]
   ["cost-center-match"             "Cost Center Match"]
   ["accrual-match"                 "Accrual Match"]
   ["tax-compliance"                "Tax Compliance"]
   ["resolve-uncertain-validations" "Resolve Validations"]
   ["triage"                        "Email Triage"]])


(defn ^:private prompts-section [db-pool tenant-id]
  (let [rows (queries/tenant-prompt-customizations db-pool tenant-id)
        by-key (into {}
                     (map (juxt :tenant-prompt-customization/prompt-key
                                :tenant-prompt-customization/additions)
                          rows))]
    [:section#prompts
     [:h2 "Prompt Customizations"]
     (ui/data-table
      {:columns [{:key :prompt  :label "Prompt"}
                 {:key :preview :label "Customization"}
                 {:key :actions :label ""}]
       :rows
       (for [[k label] known-prompt-keys
             :let [text (get by-key k)]]
         {:prompt  label
          :preview (if (clojure.string/blank? text)
                     [:span.empty-state-inline "Default"]
                     [:span {:title text}
                      (if (> (count text) 80)
                        (str (subs text 0 80) "…")
                        text)])
          :actions [:a.btn-icon
                    {:href (str "/organizations/-/tenants/"
                                tenant-id "/prompts?key=" k)
                     :title "Edit this prompt"}
                    [:iconify-icon {:icon "lucide:pencil"}]]})})]))

Make sure clojure.string (alias string) is already required in the ns block (Task 8 added it).

Verify: tenants with customizations show preview text; tenants without show "Default" for all 8 keys. Clicking the pencil opens the existing prompts editor.

git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): prompt customizations overview on tenant detail page"

Task 15: File Store section (§7)

Files:

grep -n "defn" src/com/getorcha/admin/http/tenants/file_store.clj | head

Identify the function that fetches the active file-store config for a tenant (e.g. load-file-store-config, get-active-backend). Reuse it by requiring the namespace into tenants.clj.

Require [com.getorcha.admin.http.tenants.file-store :as admin.http.tenants.file-store] and write:

(defn ^:private file-store-section [db-pool tenant-id]
  (let [config (admin.http.tenants.file-store/load-active-config db-pool tenant-id)]
    [:section#file-store
     [:h2 "File Store (FP&A)"]
     [:p.section-hint
      [:a {:href (str "/organizations/-/tenants/" tenant-id "/file-store")}
       "Configure backend"]]
     (if config
       [:dl.tenant-section-dl
        [:dt "Backend"] [:dd (name (:backend config))]
        [:dt "Target"]  [:dd (:target config)]]
       [:p.empty-state "No file-store backend configured."])]))

If the expected helper doesn't exist, add one in admin/http/tenants/file_store.clj (public function that returns a map or nil) and use it from the section. If the schema is too complex to summarize here, render just "Configured" / "Not configured" + link.

git add src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/http/tenants/file_store.clj
git commit -m "feat(admin): file store summary on tenant detail page"

Task 16: Notifications section (§8)

Files:

(defn tenant-notifications-summary
  "Channel and workflow counts for a tenant."
  [db-pool tenant-id]
  (jdbc/execute-one!
   db-pool
   ["SELECT
       (SELECT COUNT(*) FROM notification_channel
         WHERE notification_channel.tenant_id = ?) as channel_count,
       (SELECT COUNT(*) FROM notification_channel
         WHERE notification_channel.tenant_id = ?
           AND notification_channel.status = 'active') as active_channels,
       (SELECT COUNT(*) FROM notification_workflow
         WHERE notification_workflow.tenant_id = ?) as workflow_count
     FROM (SELECT 1) _"
    tenant-id tenant-id tenant-id]))
(defn ^:private notifications-section [db-pool tenant-id]
  (let [stats (queries/tenant-notifications-summary db-pool tenant-id)]
    [:section#notifications
     [:h2 "Notifications"]
     [:p.section-hint
      [:a {:href "/settings/notifications"} "Manage notifications in settings"]]
     [:dl.tenant-section-dl
      [:dt "Channels"]
      [:dd (str (or (:active-channels stats) 0) " active of "
                (or (:channel-count stats) 0) " total")]
      [:dt "Workflows"]
      [:dd (str (or (:workflow-count stats) 0))]]]))
git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): notifications summary on tenant detail page"

Task 17: Booking History section (§9)

Files:

(defn tenant-booking-history-summary
  "Latest booking-history upload and total items for a tenant."
  [db-pool tenant-id]
  (jdbc/execute-one!
   db-pool
   ["SELECT
       (SELECT COUNT(*) FROM booking_history_item
         WHERE booking_history_item.tenant_id = ?) as item_count,
       (SELECT MAX(uploaded_at) FROM booking_history_upload
         WHERE booking_history_upload.tenant_id = ?) as last_upload_at,
       (SELECT COUNT(*) FROM booking_history_upload
         WHERE booking_history_upload.tenant_id = ?) as upload_count
     FROM (SELECT 1) _"
    tenant-id tenant-id tenant-id]))
(defn ^:private booking-section [db-pool tenant-id]
  (let [stats (queries/tenant-booking-history-summary db-pool tenant-id)]
    [:section#booking
     [:h2 "Booking History"]
     [:p.section-hint
      [:a {:href "/settings/booking-history"} "Upload booking history in settings"]]
     [:dl.tenant-section-dl
      [:dt "Total items"] [:dd (ui/format-number (or (:item-count stats) 0))]
      [:dt "Latest upload"]
      [:dd (or (ui/format-relative-timestamp (:last-upload-at stats)) "—")]
      [:dt "Total uploads"]
      [:dd (ui/format-number (or (:upload-count stats) 0))]]]))
git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): booking history summary on tenant detail page"

Task 18: API Keys section (§10) — inline create/revoke

Reuse the renderer from admin/http/api_keys.clj for the row format; filter to this tenant.

Files:

Change the defn's ^:private to public. Update any internal callers accordingly (all callers are already inside the same namespace, so removing private-ness is a no-op for them).

(defn tenant-api-keys
  "API keys for one tenant. Joins organization because the reused
   api-key-row renderer expects org-name under the :organization/name
   qualified key. Uses aliased columns (`tenant_name`, `creator_email`,
   `creator_name`, `organization_name`) to match the fallbacks already
   coded into `api-key-row`."
  [db-pool tenant-id]
  (jdbc/execute!
   db-pool
   ["SELECT api_key.*,
            tenant.name as tenant_name,
            organization.name as organization_name,
            identity.email as creator_email,
            identity.display_name as creator_name
     FROM api_key
     JOIN tenant ON api_key.tenant_id = tenant.id
     JOIN organization ON tenant.organization_id = organization.id
     JOIN identity ON api_key.created_by = identity.id
     WHERE api_key.tenant_id = ?
     ORDER BY api_key.created_at DESC"
    tenant-id]))

In tenants.clj, require [com.getorcha.admin.http.api-keys :as admin.http.api-keys] and add:

(defn ^:private api-keys-section [db-pool tenant-id]
  (let [keys (queries/tenant-api-keys db-pool tenant-id)]
    [:section#api-keys
     [:h2 "API Keys"]
     [:p.section-hint
      [:a {:href (str "/api-keys?tenant=" tenant-id)}
       "Create a new key in the global API keys page"]]
     (if (seq keys)
       [:div.table-container
        [:table.data-table
         [:thead
          [:tr
           [:th "Name / Prefix"] [:th "Permissions"] [:th "Created"]
           [:th "Last used"] [:th "Status"] [:th "Actions"]]]
         [:tbody#api-keys-tbody
          (for [k keys]
            (admin.http.api-keys/api-key-row k))]]]
       [:p.empty-state "No API keys for this tenant."])]))

Note: the global API-key create page is preserved unchanged. Inline create on the detail page is out of scope unless the reviewer wants it; the spec says "Create/revoke inline (existing logic from admin/http/api_keys.clj, with the tenant filter pre-applied)." Revocation already works via the POST /api-keys/:id/revoke that the api-key-row button references. Creation pre-filtered to the tenant is a small UX delta — add a Create button that opens the existing create form with the tenant pre-selected:

Add a Create button above the table:

[:div.section-actions
 [:button.btn.btn-primary
  {:type      "button"
   :hx-get    (str "/api-keys/new?tenant=" tenant-id)
   :hx-target "#modal"
   :hx-swap   "innerHTML"}
  [:iconify-icon {:icon "lucide:plus"}]
  " New API Key"]]

And the #modal div at the bottom of the detail page (if not already present). Add it once in render-detail-page:

[:div#modal]

Add a new handler in admin/http/api_keys.clj for GET /api-keys/new returning a modal that pre-selects the tenant from the tenant query param. Mirror create-api-key-form but render inside a modal. This is additive; don't touch the existing top-level create-api-key-form.

(defn ^:private create-api-key-modal
  [tenants identities pre-selected-tenant-id]
  [:div.modal-backdrop {:onclick "if(event.target === this) this.remove()"}
   [:div.modal-content
    [:div.modal-header
     [:h3 "Create API Key"]
     [:button.modal-close {:type "button"
                           :onclick "this.closest('.modal-backdrop').remove()"}
      [:iconify-icon {:icon "lucide:x"}]]]
    [:div.modal-body
     (let [select-with-default
           (fn [tenants]
             [:select.form-select
              {:id "api-key-tenant" :name "tenant-id" :required true}
              [:option {:value ""} "Select tenant..."]
              (for [{:tenant/keys [id name] :as t} tenants
                    :let [organization-name
                          (or (:organization/organization-name t)
                              (:organization-name t))]]
                [:option {:value    (str id)
                          :selected (= id pre-selected-tenant-id)}
                 (str organization-name " / " name)])])]
       (create-api-key-form-with-custom-tenant-select
        tenants identities select-with-default))]]])

Simplest: leave create-api-key-form alone; in the modal, inline-render a copy with the select defaulted. Or refactor the form to accept the select element as a parameter. Pick one, keep it bounded.

Register the modal route:

["/new"
 {:name ::new
  :get  {:parameters {:query [:map [:tenant {:optional true} :uuid]]}
         :handler    #'new!}}]

Where new! fetches tenants + identities and calls create-api-key-modal.

Verify:

git add src/com/getorcha/admin/db/queries.clj src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/http/api_keys.clj
git commit -m "feat(admin): API keys section with scoped create and inline revoke"

Task 19: QA Dataset section (§11)

Reuse the existing qa-dataset-summary query.

Files:

(defn ^:private qa-section [db-pool tenant-id]
  (let [stats (queries/qa-dataset-summary db-pool tenant-id)]
    [:section#qa
     [:h2 "QA Dataset"]
     [:p.section-hint
      [:a {:href (str "/qa-dataset?tenant=" tenant-id)}
       "Open the QA dataset page"]]
     [:dl.tenant-section-dl
      [:dt "Total items"]  [:dd (ui/format-number (or (:total-items stats) 0))]
      [:dt "Pending"]      [:dd (ui/format-number (or (:pending-review stats) 0))]
      [:dt "Approved"]     [:dd (ui/format-number (or (:approved stats) 0))]
      [:dt "Rejected"]     [:dd (ui/format-number (or (:rejected stats) 0))]]]))
git add src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): QA dataset summary on tenant detail page"

Task 20: Stats / Cost section (§12)

Token spend chart (last 30 days), OCR pages, ingestion success rate, avg processing time. Reuse existing queries with tenant filter.

Files:

(defn ^:private stats-section [db-pool tenant-id]
  (let [tenant-str (str tenant-id)
        totals     (queries/ingestion-totals-filtered db-pool 30 tenant-str)
        avg-times  (queries/processing-time-averages-filtered
                    db-pool 30 tenant-str)
        today      (java.time.LocalDate/now)
        from       (.minusDays today 30)
        daily      (queries/daily-token-usage-filtered
                    db-pool from today tenant-str)
        total      (or (:total totals) 0)
        completed  (or (:completed totals) 0)
        success    (if (pos? total)
                     (* 100.0 (/ completed (double total)))
                     0.0)]
    [:section#stats
     [:h2 "Stats & Cost"]
     [:p.section-hint
      [:a {:href (str "/costs?tenant=" tenant-id)}
       "Open the cost dashboard for this tenant"]]
     (ui/stat-grid
      [{:icon "lucide:check-circle" :label "Success rate (30d)"
        :value (format "%.1f%%" success)
        :variant (cond
                   (>= success 95) :success
                   (>= success 80) :warning
                   :else :danger)}
       {:icon "lucide:clock"
        :label "Avg processing (30d)"
        :value (ui/format-duration (:avg-total-seconds avg-times))}
       {:icon "lucide:file-text"
        :label "Ingestions (30d)"
        :value (ui/format-number total)}])
     (ui/chart-container {:id "tenant-tokens-chart" :title "Tokens by day (30d)"})
     (ui/chart-script
      {:id   "tenant-tokens-chart"
       :type :line
       :data {:labels   (map (comp str :day) daily)
              :datasets [{:label "Extraction"
                          :data  (map #(+ (or (:extraction-in %) 0)
                                          (or (:extraction-out %) 0)) daily)}
                         {:label "Transcription"
                          :data  (map #(+ (or (:transcription-in %) 0)
                                          (or (:transcription-out %) 0)) daily)}]}})]))

Note: daily-token-usage-filtered accepts tenant-id as a string (used raw in SQL); check the existing impl for type expectations.

git add src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(admin): stats & cost section with chart on tenant detail page"

Task 21: End-to-end verification + final cleanup of render-detail-page

At this point every slot in the sections map is a real renderer. This task verifies the whole detail page and removes the now-unused section-placeholder helper.

Files:

Grep inside tenants.clj:

grep -n "section-placeholder" src/com/getorcha/admin/http/tenants.clj

Expected: one match (the defn definition). If any map entry still wraps a placeholder, that's an unfinished section — address it before proceeding.

Delete the section-placeholder defn.

Run clj-kondo --lint src test dev. Clean.

Walk every section of a tenant detail page and verify each matches the underlying DB:

git add src/com/getorcha/admin/http/tenants.clj
git commit -m "refactor(admin): wire all tenant detail sections into render-detail-page"

Task 22: Final cleanup pass

Files:

Search for ^:private defns in both files and confirm each has a caller:

grep -n "defn ^:private" src/com/getorcha/admin/http/organizations.clj src/com/getorcha/admin/http/tenants.clj

Delete any orphans left from the refactor (for example, if edit-tenant-modal is no longer reachable because all tenant edits now happen on the detail page, remove the modal + its route).

Confirm admin/ui/components.clj nav-items shows {:label "Organizations"} for the /organizations entry (it does, per SP1 output).

clj-kondo --lint src test dev

Expected: zero warnings including info-level.

Against a seeded local DB:

If any changes:

git add -u
git commit -m "chore(admin): cleanup after tenant detail refactor"

Else skip.


Self-Review Notes

Spec coverage per section:

Open risks flagged to the executing engineer:

Review feedback already applied to this document:

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-04-24-admin-org-tenant-panel.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?