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:
ReadableColumn extension at src/com/getorcha/db.clj:86-97 (uses cheshire.core/parse-string v true). So (get-in row [:tenant-datev-integration/metadata :company-name]) works; string-keyed access does not.next-jdbc default result builder is as-kebab-maps; result keys are table-qualified (e.g. :organization/name, :ap-doc-source-email/email-address). Avoid SQL column aliases like organization.name as organization_name — they produce :unqualified/organization-name keys that require defensive fallbacks. Select the raw column (SELECT organization.name, ...) and destructure :organization/name.admin/http.clj are merged into the same path-space. A :put at /organizations/-/tenants/:id in one namespace and a :get at the same path in another is a conflict. All methods on a shared path must live in one routes function.Prerequisite: Sub-project 1 (legal_entity → tenant rename) must be merged first. Post-SP1 schema:
organization (formerly tenant)tenant (formerly legal_entity) with columns id, name, organization_id, company_address, company_vat_id, company_tax_id, company_country, created_at, updated_atorganization_membership (formerly tenant_membership), tenant_datev_integration, ap_doc_source / ap_doc_source_email (with tenant_id), gl_accounts_dataset / cost_center_dataset / business_partner_dataset (with tenant_id), notification_channel (with tenant_id), notification_workflow (with tenant_id), booking_history_upload / booking_history_item, api_key (with tenant_id), tenant_prompt_customization, ap_qa_dataset_item (with tenant_id).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:
admin/db/queries.clj already aggregates all admin dashboard queries; the new queries fit the existing pattern.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.
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:
Modify: src/com/getorcha/admin/db/queries.clj — remove prompt-customizations-matrix
Modify: src/com/getorcha/admin/http/organizations.clj — remove matrix helpers + the ui/section call that renders it
Step 1: Remove the prompt-customizations-matrix function in queries.clj
Delete the (defn prompt-customizations-matrix ...) form (search for the symbol) and the ;; Prompt Customizations Matrix banner comment immediately above it.
organizations.cljDelete these top-level forms (line ranges approximate, confirm by searching):
(def ^:private prompt-categories ...)(def ^:private prompt-labels ...)(defn ^:private truncate-text ...)(defn ^:private build-customizations-matrix ...)(defn ^:private prompt-customizations-matrix-table ...)In render-organizations-page:
Remove the let-bindings customizations-raw and customizations.
Remove the ;; Prompt Customizations Matrix section block that calls (ui/section ... (prompt-customizations-matrix-table customizations)).
Step 3: Lint
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"
organization-overview query with doc/review/activity aggregatesThe spec requires org-row columns for "Docs (last 30d)", "Review backlog", and "Last activity". The existing query only returns tenant_count + user_count.
Files:
Modify: src/com/getorcha/admin/db/queries.clj — replace organization-overview
Step 1: Rewrite organization-overview to include the new aggregates
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"
tenants-by-organization query for expansion rowsThe expansion endpoint will call this to fetch just the tenants under one organization with their per-tenant stats.
Files:
Modify: src/com/getorcha/admin/db/queries.clj — add tenants-by-organization
Step 1: Add the query
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"
organization-row with chevron + new columnsUpdate 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:
Modify: src/com/getorcha/admin/http/organizations.clj — replace organization-row and organizations-table; drop the separate tenants-table from this namespace (the nested table renders inside the expansion response)
Step 1: Add a format-relative-timestamp helper near the top of the namespace
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)))))))
organization-rowReplace 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"}])))
organizations-tableReplace 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]]))
tenants-table and tenant-rowDelete the tenants-table and tenant-row defns from organizations.clj. The nested row renderer lives inside the expansion endpoint (added in Task 5).
create-tenant! response — tbody is goneThe 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:
Only one section ("Organizations") — no standalone "Tenants" section, no matrix.
Each org row shows: chevron, Name, Slug, Tenants, Users, Docs (30d), Review, Last activity, Actions.
Clicking the chevron fires a request that 404s (the endpoint doesn't exist yet) — that's fine; we implement it in Task 5.
Step 9: Commit
git add src/com/getorcha/admin/http/organizations.clj
git commit -m "refactor(admin): rework organizations table with chevron and activity columns"
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:
Modify: src/com/getorcha/admin/http/organizations.clj — add expand-tenants, collapse-organization, nested row renderer, routes
Step 1: Add the nested tenant-row renderer
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:
Click the chevron: the nested tenants table appears below the org row, chevron flips to down.
Click again: nested table collapses back to the empty placeholder, chevron flips back to right.
Tenant names in the nested table are links to /organizations/-/tenants/:id (the detail page 404s — we implement it in Task 7).
Org with no tenants: nested table shows the "No tenants" empty state.
Step 8: Commit
git add src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): add HTMX expansion and collapse for organization rows"
create tenant form accessible from the top-level pagePre-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:
Modify: src/com/getorcha/admin/http/organizations.clj — add a scoped-create modal endpoint; add an action button on the org row
Step 1: Add a create-tenant modal renderer and handler
(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}}]
organization-rowInside 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.
Step 5: Manually verify
Click "+" on an org row → modal opens scoped to that org.
Submit with blank name → inline error inside the modal.
Submit with valid name → modal closes, page reloads, new tenant visible when the org is expanded.
Step 6: Commit
git add src/com/getorcha/admin/http/organizations.clj
git commit -m "feat(admin): add scoped create-tenant modal from organization row actions"
admin/http/tenants.clj with detail-page shell and routeBefore 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
Modify: src/com/getorcha/admin/http.clj — require + register routes
Step 1: Create the namespace
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.
/-/tenants/:id subtree from organizations.cljIn 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.
admin/http.cljIn src/com/getorcha/admin/http.clj:
:require:[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:
Organizations → <Org Name> → <Tenant Name>.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"
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:
Modify: src/com/getorcha/admin/http/tenants.clj — add identity renderer + field editor endpoints + PUT handler
Modify: src/com/getorcha/admin/http/organizations.clj — extract country-options and country-select so they can be reused (or duplicate the minimal code here — prefer extraction)
Step 1: Extract country-options and country-select to admin/ui/components.clj
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:
Delete the private country-options def and country-select defn.
Replace all callers of country-select (just create-tenant-form, edit-tenant-modal, and the new create-tenant-modal from Task 6) with ui/country-select.
Step 2: Add the identity-section renderer
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.
Step 6: Manually verify
Open a tenant detail page. Identity section shows five labeled fields with their current values.
Click the pencil on "Name" → form appears in place. Type a new value → submit → span reappears with new value; breadcrumb crumb + H1 title update without a full reload.
Click pencil on "Company Address" → textarea.
Click pencil on "Country" → country dropdown pre-selected to current value.
Submit empty "Name" → inline error, value unchanged.
Submit empty "VAT ID" → value clears, span shows —.
Step 7: Commit
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"
Fills in the #tenant-quick-stats placeholder with docs, review backlog, active sources, DATEV status, token spend (30d), last activity.
Files:
Modify: src/com/getorcha/admin/db/queries.clj — add tenant-detail-header-stats
Modify: src/com/getorcha/admin/http/tenants.clj — render the strip
Step 1: Add the stats query
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]))
tenants.cljAdd 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:
In admin/ui/components.clj, add:
(def ^:private relative-formatter
(DateTimeFormatter/ofPattern "yyyy-MM-dd HH:mm"))
(defn format-relative-timestamp
"Format a timestamp as relative text, falling back to an absolute value."
[ts] ;; body as in organizations.clj Task 4
...)
and add the three imports (java.time.Instant, java.time.ZoneId, java.time.format.DateTimeFormatter, java.time.temporal.ChronoUnit) to the ns :import block.
In admin/http/organizations.clj, delete the local copies and call ui/format-relative-timestamp.
In admin/http/tenants.clj, call ui/format-relative-timestamp.
Step 3: Swap the placeholder in header for the strip
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"
Files:
Modify: admin/db/queries.clj — add tenant-ingestion-sources
Modify: admin/http/tenants.clj — render the section
Step 1: Query
(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"
Files:
Modify: admin/db/queries.clj — add tenant-datev-integration
Modify: admin/http/tenants.clj — render
Step 1: Query
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"
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:
tenant_id (uuid)integration_type (enum oauth_integration_type; currently only 'google_drive')is_active (bool)connected_by_user_id (uuid → identity.id)refresh_token_expires_at (timestamptz, nullable)config (jsonb; Google Drive stores {:folder-id "..." :folder-name "..."} per src/com/getorcha/app/http/settings/google_drive.clj:253)metadata (jsonb)created_at, updated_at (timestamptz)Files:
Modify: admin/db/queries.clj — add tenant-oauth-integrations
Modify: admin/http/tenants.clj
Step 1: Confirm config layout before coding
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"
Three subsections: GL accounts, cost centers, business partners — each a row-count + active flag + last-updated + link to /settings/data.
Files:
Modify: admin/db/queries.clj — add tenant-master-data-summary
Modify: admin/http/tenants.clj
Step 1: Query
(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"
Lists all 8 prompt keys with current additions; each is a link to the existing prompts editor under /organizations/-/tenants/:id/prompts?key=....
Files:
Modify: admin/db/queries.clj — add tenant-prompt-customizations
Modify: admin/http/tenants.clj
Step 1: Query
(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"
Files:
Modify: admin/http/tenants.clj — render (no new query needed; delegates to file-store endpoint)
Step 1: Find the existing file-store query
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"
Files:
Modify: admin/db/queries.clj — add tenant-notifications-summary
Modify: admin/http/tenants.clj
Step 1: Query
(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"
Files:
Modify: admin/db/queries.clj — add tenant-booking-history-summary
Modify: admin/http/tenants.clj
Step 1: Query
(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"
Reuse the renderer from admin/http/api_keys.clj for the row format; filter to this tenant.
Files:
Modify: admin/db/queries.clj — add tenant-api-keys
Modify: admin/http/tenants.clj
Modify: admin/http/api_keys.clj — expose api-key-row publicly so we can reuse it (or duplicate the renderer — prefer reuse)
Step 1: Make api-key-row public in admin/http/api_keys.clj
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"
Reuse the existing qa-dataset-summary query.
Files:
Modify: admin/http/tenants.clj
Step 1: Renderer
(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"
Token spend chart (last 30 days), OCR pages, ingestion success rate, avg processing time. Reuse existing queries with tenant filter.
Files:
Modify: admin/http/tenants.clj
Step 1: Compose data using existing queries
(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"
render-detail-pageAt 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:
Modify: src/com/getorcha/admin/http/tenants.clj
Step 1: Confirm the sections map has no placeholders left
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:
Compare sources to SELECT * FROM ap_doc_source WHERE tenant_id = ?.
Compare datev to SELECT * FROM tenant_datev_integration WHERE tenant_id = ?.
Master data counts match SELECT COUNT(*) on each dataset table.
Prompts: create a customization via /organizations/-/tenants/:id/prompts, reload detail page — preview visible.
API keys: create one via the modal, verify the row appears; revoke it, verify it marks revoked.
Step 3: Commit
git add src/com/getorcha/admin/http/tenants.clj
git commit -m "refactor(admin): wire all tenant detail sections into render-detail-page"
Files:
Modify: src/com/getorcha/admin/http/organizations.clj
Modify: src/com/getorcha/admin/http/tenants.clj
Step 1: Sweep for unused private helpers
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).
Step 2: Ensure all :require lines are still used, alphabetically ordered, no stale aliases
Step 3: Navigation label check
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:
/organizations renders; expansion works for orgs with tenants; creating a tenant updates counts after reload.
Every section on at least two different tenant detail pages renders without exceptions.
Inline identity edits update the breadcrumb and title in place.
All link-outs (/settings/*, /costs, /qa-dataset, /api-keys, prompts editor, file-store editor) navigate correctly with the tenant context preserved.
Step 6: Commit
If any changes:
git add -u
git commit -m "chore(admin): cleanup after tenant detail refactor"
Else skip.
Spec coverage per section:
Open risks flagged to the executing engineer:
tenant_datev_integration.connected_by_user_id; fallback branch noted in Step 1.tenant_oauth_integration (confirmed in the rename migration); the JSONB config shape is confirmed via google_drive.clj:253.admin/http/tenants/file_store.clj; add one if absent.create! logic; the exposed field-selection helper in admin/http/api_keys.clj may need a small refactor to accept a tenant-preselected select element.Review feedback already applied to this document:
/organizations/-/tenants/:id methods live in one namespace (tenants.clj); expansion paths use /expanded-tenants/... to avoid trie ambiguity.tenant_oauth_integration schema.create-tenant! response rewrite merged into Task 4 so no in-between broken window.tenant-api-keys joins organization.identity-section uses explicit lookups (no name shadow).render-detail-page uses an anchor → renderer map; section tasks swap one entry each.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?