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: Add click-to-edit inline editing to every structured-data value in the document detail view, with pixel-perfect stability at rest and on hover.
Architecture: A single editable-value hiccup helper wraps display values in a <span> with data-field-path/data-field-type attributes. Display components (labeled-field, value-row, party-card, line-items-table, line-item-card, delivery-section) gain an opt-in :editable or :editable-path-prefix option. One delegated JS handler in editable-fields.js listens for click/keydown/blur on .detail-container descendants and swaps between display and input states. No backend, no persistence — edits live in the DOM for the page session.
Tech Stack: Clojure 1.12 + Hiccup2, vanilla JavaScript, CSS. No new dependencies except adding cheshire.core as a require in components.clj (already a project dependency).
Spec: docs/superpowers/specs/2026-04-09-inline-editing-ui-design.md
Verification: There are no automated tests for this feature. Verification is visual and manual, performed via playwright-cli against the running development server. Each task that produces a user-visible change has a manual verification step.
REPL workflow:
clj-nrepl-eval --discover-ports to find the running nREPL (the dev system is typically on a port in the project directory).clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload)" to reload changed namespaces.(integrant.repl/reset) if you changed component implementation (ig/init-key) — not needed for pure hiccup function edits.resources/app/public/ with a query-string cache buster (?v=<uuid>) — edits take effect on the next page load without a server restart.New files:
resources/app/public/js/editable-fields.js — ~130 lines. Vanilla JS. Delegated event handling on .detail-container. State machine (rest → edit → save/cancel → rest). Per-type input builders. No external dependencies.Modified files:
src/com/getorcha/app/ui/components.clj — add cheshire.core require, add editable-value helper, add :editable / :editable-path-prefix options to labeled-field, value-row, invoice-header, party-card, line-items-table, line-item-card, delivery-section, payment-summary.src/com/getorcha/app/http/documents/view/shared.clj — include <script src="/public/js/editable-fields.js"> inside the detail-container renderer.src/com/getorcha/app/http/documents/view/invoice.clj — pass :editable-path-prefix [:structured-data] to invoice-header, line-items-table, enhanced-line-items-table, payment-summary, delivery-section.src/com/getorcha/app/http/documents/view/purchase_order.clj — same.src/com/getorcha/app/http/documents/view/goods_received_note.clj — same.src/com/getorcha/app/http/documents/view/contract.clj — same.src/com/getorcha/app/http/documents/view/notice.clj — same.resources/app/public/css/style.css — append .editable-value rules (~90 lines).editable-value helperFiles:
Modify: src/com/getorcha/app/ui/components.clj (ns form at the top + add new function after format-currency around line 521)
Step 1: Add cheshire.core to the components.clj require list
Current ns form (line 1-7):
(ns com.getorcha.app.ui.components
"Reusable UI components.
All functions return hiccup data structures."
(:require [clojure.string :as string])
(:import [java.text DecimalFormat DecimalFormatSymbols]
[java.util Locale]))
Change to (requires alphabetized per project convention):
(ns com.getorcha.app.ui.components
"Reusable UI components.
All functions return hiccup data structures."
(:require [cheshire.core :as json]
[clojure.string :as string])
(:import [java.text DecimalFormat DecimalFormatSymbols]
[java.util Locale]))
editable-value functionInsert this function after format-currency (around line 522 in the current file, immediately before (defn format-date ...)).
(defn editable-value
"Wraps a display value in a span that client JS turns into an inline editor.
At rest this renders as a plain inline span with zero padding/margin/border —
pixel-identical to the passed display hiccup. On hover (CSS) an outline and
background tint appear. On click (JS) the inner content is replaced by an
input / select / textarea that inherits all typography.
path - vector like [:structured-data :invoice-number]
serialised to :data-field-path via pr-str
field-type - :text | :number | :currency | :date | :enum | :multiline
opts - map (all keys optional):
:raw-value - original value (string-ified for :data-raw-value)
:currency - currency code for :currency fields (e.g. \"EUR\")
:options - vector of option strings for :enum fields
:class - extra CSS class on the wrapper (e.g. \"enum-wrapper\"
or \"currency-wrapper\" — used by the CSS and JS to
special-case layout per type)
display - the hiccup that's currently rendered for this value"
[path field-type opts & display]
[:span
(cond-> {:class (cond-> "editable-value"
(:class opts) (str " " (:class opts)))
:data-field-path (pr-str path)
:data-field-type (name field-type)}
(some? (:raw-value opts)) (assoc :data-raw-value (str (:raw-value opts)))
(:currency opts) (assoc :data-currency (:currency opts))
(seq (:options opts)) (assoc :data-field-options
(json/generate-string (:options opts))))
display])
Discover the running nREPL port:
clj-nrepl-eval --discover-ports
Reload (replace <PORT> with the actual port):
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload)"
Expected: nil (success). Any error message indicates a syntax problem — fix and reload.
clj-nrepl-eval -p <PORT> "(com.getorcha.app.ui.components/editable-value [:structured-data :invoice-number] :text {:raw-value \"INV-42\"} [:div.field-value \"INV-42\"])"
Expected output — note that :data-field-path is the pr-str of the path vector, which includes the brackets:
[:span {:class "editable-value"
:data-field-path "[:structured-data :invoice-number]"
:data-field-type "text"
:data-raw-value "INV-42"}
[:div.field-value "INV-42"]]
clj-kondo --lint src/com/getorcha/app/ui/components.clj
Expected: linting took Xms, errors: 0, warnings: 0. Any new warnings must be fixed.
git add src/com/getorcha/app/ui/components.clj
git commit -m "feat(app): add editable-value hiccup helper"
.editable-value statesFiles:
Modify: resources/app/public/css/style.css (append at end of file)
Step 1: Append CSS block
Open resources/app/public/css/style.css and append this block to the end of the file:
/* ============================================================
* Inline editable values — hover to reveal, click to edit.
* Critical rule: every state must be pixel-identical at rest.
* `outline` and `box-shadow` live outside the box model; they
* never shift layout. No borders, no padding, no margin on
* .editable-value itself — it inherits everything from parent.
* ============================================================ */
.editable-value {
cursor: text;
outline: 1px solid transparent;
outline-offset: 0;
background-color: transparent;
border-radius: 3px;
transition: outline-color 120ms ease, background-color 120ms ease;
}
.editable-value:hover {
outline-color: rgba(88, 166, 255, 0.35);
background-color: rgba(88, 166, 255, 0.08);
}
.editable-value.is-editing {
outline-color: #58a6ff;
background-color: rgba(88, 166, 255, 0.12);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
.editable-value.is-saving {
animation: orcha-edit-flash 400ms ease-out;
}
@keyframes orcha-edit-flash {
0% {
outline-color: #3fb950;
background-color: rgba(63, 185, 80, 0.20);
}
100% {
outline-color: transparent;
background-color: transparent;
}
}
/* Inputs within an editable-value inherit typography exactly. */
.editable-value > input,
.editable-value > textarea,
.editable-value > select {
all: unset;
box-sizing: border-box;
width: 100%;
font: inherit;
color: inherit;
text-align: inherit;
white-space: inherit;
/* Safari fallback: `all: unset` leaves -webkit-user-select auto */
-webkit-user-select: text;
user-select: text;
}
.editable-value > textarea {
display: block;
resize: none;
overflow: hidden;
min-height: 1em;
}
/* Currency fields split amount + suffix; only .amount becomes editable. */
.editable-value.currency-wrapper > .currency-suffix {
pointer-events: none;
}
/* Enum fields: the select overlays the badge, invisible but clickable. */
.editable-value.enum-wrapper {
position: relative;
}
.editable-value.enum-wrapper > select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
The CSS file is served with a query-string cache buster (?v=<uuid>). A page reload is enough. Quick check via playwright-cli:
playwright-cli open http://localhost:8080/public/css/style.css
playwright-cli eval "document.body.innerText.includes('.editable-value')"
Expected: true. If false, the server didn't pick up the file — restart if needed.
git add resources/app/public/css/style.css
git commit -m "feat(app): add CSS states for editable-value hover/edit/saving"
editable-fields.jsFiles:
Create: resources/app/public/js/editable-fields.js
Step 1: Create the file
Create resources/app/public/js/editable-fields.js with the following complete content:
/*
* editable-fields.js — inline editing for document detail view.
*
* Attaches delegated listeners to the document; only matches
* targets inside `.detail-container`. Swaps display spans with
* type-appropriate inputs on click, saves on Enter/blur/Tab,
* cancels on Escape. No network, no state persistence.
*/
(function () {
'use strict';
const EDITING = 'is-editing';
const SAVING = 'is-saving';
const SAVE_FLASH_MS = 400;
document.addEventListener('click', onDocumentClick, true);
function onDocumentClick(e) {
const el = e.target.closest('.editable-value');
if (!el) return;
if (!document.querySelector('.detail-container')?.contains(el)) return;
if (el.classList.contains(EDITING)) return;
if (el.classList.contains(SAVING)) return;
// For currency fields, clicking the suffix should still open the amount.
startEdit(el);
e.preventDefault();
e.stopPropagation();
}
function startEdit(el) {
el.dataset.originalHtml = el.innerHTML;
const type = el.dataset.fieldType;
const target = buildInput(type, el);
if (!target) return;
el.classList.add(EDITING);
const input = target.input;
input.addEventListener('keydown', onInputKeydown);
input.addEventListener('blur', onInputBlur);
// Allow the browser to paint before focusing so autogrow measurements work.
requestAnimationFrame(() => {
input.focus();
if (typeof input.select === 'function') input.select();
if (type === 'multiline') autogrow(input);
});
}
/* ---------- input builders ---------- */
function buildInput(type, el) {
switch (type) {
case 'text':
case 'number':
case 'date':
return buildScalarInput(type, el);
case 'currency':
return buildCurrencyInput(el);
case 'enum':
return buildEnumSelect(el);
case 'multiline':
return buildTextarea(el);
default:
return null;
}
}
function buildScalarInput(type, el) {
const input = document.createElement('input');
input.type = 'text';
if (type === 'number') input.inputMode = 'decimal';
input.value = el.textContent.trim();
el.textContent = '';
el.appendChild(input);
return { input: input };
}
function buildCurrencyInput(el) {
// Display shape at rest:
// <span class="editable-value currency-wrapper">
// <span class="amount">1.234,56</span>
// <span class="currency-suffix"> EUR</span>
// </span>
const amount = el.querySelector('.amount');
const suffix = el.querySelector('.currency-suffix');
if (!amount) return buildScalarInput('number', el);
const input = document.createElement('input');
input.type = 'text';
input.inputMode = 'decimal';
input.value = amount.textContent.trim();
input.className = 'amount';
amount.replaceWith(input);
// The suffix stays untouched; restore original HTML on cancel via dataset.
return { input: input };
}
function buildEnumSelect(el) {
const optionsJson = el.dataset.fieldOptions || '[]';
let options = [];
try { options = JSON.parse(optionsJson); } catch (_) { options = []; }
const current = el.textContent.trim();
const select = document.createElement('select');
options.forEach((opt) => {
const option = document.createElement('option');
option.value = String(opt);
option.textContent = String(opt);
if (String(opt) === current) option.selected = true;
select.appendChild(option);
});
el.appendChild(select);
// Listen to change so picking an option commits immediately.
select.addEventListener('change', () => commit(select));
return { input: select };
}
function buildTextarea(el) {
const height = el.getBoundingClientRect().height;
const textarea = document.createElement('textarea');
textarea.value = el.textContent;
textarea.style.height = Math.max(height, 20) + 'px';
el.textContent = '';
el.appendChild(textarea);
textarea.addEventListener('input', () => autogrow(textarea));
return { input: textarea };
}
function autogrow(textarea) {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
/* ---------- keyboard / blur handling ---------- */
function onInputKeydown(e) {
const input = e.currentTarget;
if (e.key === 'Escape') {
e.preventDefault();
cancel(input);
return;
}
if (e.key === 'Enter') {
const multiline = input.tagName === 'TEXTAREA';
if (multiline && e.shiftKey) return; // allow newline
e.preventDefault();
commit(input);
return;
}
if (e.key === 'Tab') {
e.preventDefault();
commit(input);
return;
}
}
function onInputBlur(e) {
const input = e.currentTarget;
// Guard: blur can fire during commit/cancel as the DOM mutates.
if (!input.isConnected) return;
commit(input);
}
/* ---------- commit / cancel ---------- */
function commit(input) {
const el = input.closest('.editable-value');
if (!el || !el.classList.contains(EDITING)) return;
const value = input.value;
const type = el.dataset.fieldType;
el.innerHTML = formatDisplay(type, value, el);
el.classList.remove(EDITING);
el.classList.add(SAVING);
setTimeout(() => el.classList.remove(SAVING), SAVE_FLASH_MS);
delete el.dataset.originalHtml;
}
function cancel(input) {
const el = input.closest('.editable-value');
if (!el) return;
el.innerHTML = el.dataset.originalHtml || '';
el.classList.remove(EDITING);
delete el.dataset.originalHtml;
}
/* ---------- display formatters ---------- */
function formatDisplay(type, value, el) {
switch (type) {
case 'currency': {
const suffix = el.dataset.currency ? ' ' + el.dataset.currency : '';
return '<span class="amount">' + escapeHtml(value) + '</span>' +
'<span class="currency-suffix">' + escapeHtml(suffix) + '</span>';
}
case 'multiline':
return escapeHtml(value);
default:
return escapeHtml(value);
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
playwright-cli open http://localhost:8080/public/js/editable-fields.js
playwright-cli eval "document.body.innerText.includes('editable-fields.js')"
Expected: true (the comment header mentions the filename).
git add resources/app/public/js/editable-fields.js
git commit -m "feat(app): add editable-fields.js client runtime"
Files:
Modify: src/com/getorcha/app/http/documents/view/shared.clj:405
Step 1: Add the script tag
Open src/com/getorcha/app/http/documents/view/shared.clj and locate the .detail-container div (currently at line 405):
[:div#document-content.detail-container
;; Left panel: PDF viewer
[:div.pdf-panel
Change to insert a script tag immediately inside the detail container:
[:div#document-content.detail-container
;; Client runtime for inline editing of structured-data values.
;; Scoped via internal check: the script only acts on elements
;; within .detail-container, so loading it here is sufficient.
[:script {:src (str "/public/js/editable-fields.js?v=" app.ui.layout/assets-version)
:defer true}]
;; Left panel: PDF viewer
[:div.pdf-panel
Note: app.ui.layout/assets-version is already aliased in this namespace (line 24). The cache-buster query string matches the pattern used elsewhere (e.g. upload.js, google-drive-picker.js).
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.http.documents.view.shared :reload)"
Expected: nil.
Open a document detail view in playwright-cli. You need a document UUID from the running system — get one via psql:
psql -h localhost -U postgres -d orcha -t -c "SELECT id FROM document WHERE status = 'completed' LIMIT 1;"
Copy the UUID, then:
playwright-cli open http://localhost:8080/documents/view/<UUID>
playwright-cli eval "Array.from(document.scripts).some(s => s.src.includes('editable-fields.js'))"
Expected: true. Also check the console for any load errors:
playwright-cli console
Expected: no errors mentioning editable-fields.js.
clj-kondo --lint src/com/getorcha/app/http/documents/view/shared.clj
Expected: errors: 0, warnings: 0 (or at least no new warnings — the file may have pre-existing ones; compare before/after).
git add src/com/getorcha/app/http/documents/view/shared.clj
git commit -m "feat(app): load editable-fields.js on document detail view"
labeled-field supports :editable optFiles:
Modify: src/com/getorcha/app/ui/components.clj:479-495
Step 1: Replace labeled-field
Current implementation (lines 479-495):
(defn labeled-field
"Renders a labeled field with optional copy button.
Options:
- :copy? - show copy button (default false)
- :copy-value - value to copy (defaults to display value if not specified)
- :monospace? - use monospace font (default false)"
[label value & {:keys [copy? copy-value monospace?]}]
[:div.field-group
[:div.field-label label]
(if (na-value? value)
[:div.field-value.na "N/A"]
(if copy?
[:div.field-value-row
[:span {:class (str "field-value" (when monospace? " monospace"))} value]
(copy-button (or copy-value value))]
[:div {:class (str "field-value" (when monospace? " monospace"))} value]))])
Replace with:
(defn labeled-field
"Renders a labeled field with optional copy button.
Options:
- :copy? - show copy button (default false)
- :copy-value - value to copy (defaults to display value if not specified)
- :monospace? - use monospace font (default false)
- :editable - map {:path [...] :type :text|:number|:currency|:date|:enum|:multiline
:raw-value ... :currency \"EUR\" :options [...]}
When present, wraps the value in editable-value so client JS
can turn it into an inline editor. Not applied when the value
is N/A (nothing to edit)."
[label value & {:keys [copy? copy-value monospace? editable]}]
[:div.field-group
[:div.field-label label]
(cond
(na-value? value)
[:div.field-value.na "N/A"]
copy?
[:div.field-value-row
(if editable
(editable-value (:path editable) (:type editable)
(select-keys editable [:raw-value :currency :options :class])
[:span {:class (str "field-value" (when monospace? " monospace"))} value])
[:span {:class (str "field-value" (when monospace? " monospace"))} value])
(copy-button (or copy-value value))]
:else
(if editable
(editable-value (:path editable) (:type editable)
(select-keys editable [:raw-value :currency :options :class])
[:div {:class (str "field-value" (when monospace? " monospace"))} value])
[:div {:class (str "field-value" (when monospace? " monospace"))} value]))])
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj
Expected: errors: 0, warnings: 0.
clj-nrepl-eval -p <PORT> "(com.getorcha.app.ui.components/labeled-field \"Invoice\" \"INV-42\" :editable {:path [:structured-data :invoice-number] :type :text :raw-value \"INV-42\"})"
Expected output structure:
[:div.field-group
[:div.field-label "Invoice"]
[:span {:class "editable-value"
:data-field-path "[:structured-data :invoice-number]"
:data-field-type "text"
:data-raw-value "INV-42"}
[:div {:class "field-value"} "INV-42"]]]
clj-nrepl-eval -p <PORT> "(com.getorcha.app.ui.components/labeled-field \"Invoice\" \"INV-42\")"
Expected:
[:div.field-group
[:div.field-label "Invoice"]
[:div {:class "field-value"} "INV-42"]]
No editable-value wrapper — the opt-in contract holds.
git add src/com/getorcha/app/ui/components.clj
git commit -m "feat(app): add :editable opt to labeled-field"
invoice-header for editable fieldsFiles:
Modify: src/com/getorcha/app/ui/components.clj:648-697 (invoice-header)
Step 1: Replace invoice-header
Current implementation (lines 648-697):
(defn invoice-header
"Renders the invoice header grid with key invoice metadata, including parties.
Shows invoice number (with copy), invoice date, service period, due date, uploaded date,
issuer/recipient party cards, and optional service classification.
Dates are formatted according to locale (currently German: DD.MM.YYYY)."
[{:keys [invoice-number invoice-date service-period due-date issuer recipient service-category]
:as _structured-data}
created-at]
(collapsible-section
"Invoice Details"
"section-invoice-details"
[:div
;; Invoice metadata in a box (consistent with party cards)
[:div.invoice-metadata-box
[:div.invoice-metadata-box-title "Invoice"]
[:div.invoice-header-grid
(labeled-field "Invoice Number" invoice-number :copy? true)
(labeled-field "Invoice Date" (format-date invoice-date))
(labeled-field "Service Period"
(when service-period
(let [{:keys [start end]} service-period]
(if (= start end)
(format-date start)
(str (format-date start) " - " (format-date end))))))
(labeled-field "Due Date" (format-date due-date))
(labeled-field "Uploaded"
(when created-at
(let [instant (if (instance? java.time.Instant created-at)
created-at
(.toInstant created-at))
formatter (java.time.format.DateTimeFormatter/ofPattern "dd.MM.yy HH:mm z")]
(.format (.atZone instant (java.time.ZoneId/of "Europe/Berlin")) formatter))))]]
;; Parties grid (issuer and recipient)
[:div.parties-grid
(if (seq issuer)
(party-card "Issuer" issuer)
[:div.party-card.empty
[:div.party-card-title "Issuer"]
[:div.empty-placeholder "No issuer information"]])
(if (seq recipient)
(party-card "Recipient" recipient)
[:div.party-card.empty
[:div.party-card-title "Recipient"]
[:div.empty-placeholder "No recipient information"]])]
;; Classification (inline within invoice details)
(when service-category
[:div.invoice-classification
[:span.invoice-classification-label "Classification"]
(service-category-badge service-category)])]))
Replace with the following. Note that party-card calls do NOT yet pass :editable-path-prefix — party-card's current signature is positional and would throw ArityException if given extra args. Task 8 updates party-card's signature and restores the wiring.
(defn invoice-header
"Renders the invoice header grid with key invoice metadata, including parties.
Shows invoice number (with copy), invoice date, service period, due date, uploaded date,
issuer/recipient party cards, and optional service classification.
Dates are formatted according to locale (currently German: DD.MM.YYYY).
Options:
- :editable-path-prefix - path vector prefix (e.g. [:structured-data]) that enables
inline editing on all descendant fields. When omitted, the
section renders read-only as before. The :uploaded date is
never editable (it's metadata)."
[{:keys [invoice-number invoice-date service-period due-date issuer recipient service-category]
:as _structured-data}
created-at
& {:keys [editable-path-prefix]}]
(let [edit (fn [k type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k)
:type type
:raw-value raw}))]
(collapsible-section
"Invoice Details"
"section-invoice-details"
[:div
;; Invoice metadata in a box (consistent with party cards)
[:div.invoice-metadata-box
[:div.invoice-metadata-box-title "Invoice"]
[:div.invoice-header-grid
(labeled-field "Invoice Number" invoice-number
:copy? true
:editable (edit :invoice-number :text invoice-number))
(labeled-field "Invoice Date" (format-date invoice-date)
:editable (edit :invoice-date :date invoice-date))
(labeled-field "Service Period"
(when service-period
(let [{:keys [start end]} service-period]
(if (= start end)
(format-date start)
(str (format-date start) " - " (format-date end))))))
(labeled-field "Due Date" (format-date due-date)
:editable (edit :due-date :date due-date))
(labeled-field "Uploaded"
(when created-at
(let [instant (if (instance? java.time.Instant created-at)
created-at
(.toInstant created-at))
formatter (java.time.format.DateTimeFormatter/ofPattern "dd.MM.yy HH:mm z")]
(.format (.atZone instant (java.time.ZoneId/of "Europe/Berlin")) formatter))))]]
;; Parties grid (issuer and recipient)
;; NOTE: party-card :editable-path-prefix wiring added in Task 8.
[:div.parties-grid
(if (seq issuer)
(party-card "Issuer" issuer)
[:div.party-card.empty
[:div.party-card-title "Issuer"]
[:div.empty-placeholder "No issuer information"]])
(if (seq recipient)
(party-card "Recipient" recipient)
[:div.party-card.empty
[:div.party-card-title "Recipient"]
[:div.empty-placeholder "No recipient information"]])]
;; Classification (inline within invoice details)
(when service-category
[:div.invoice-classification
[:span.invoice-classification-label "Classification"]
(service-category-badge service-category)])])))
Notes:
Service Period stays non-editable in this task — it's a composite (start + end dates) that requires two separate editable leaves. Out of scope for v1; left as read-only.
The uploaded field is intentionally non-editable (it's DB metadata, not extracted data).
The service-category badge is intentionally non-editable in this task (enum handling comes later in Task 12).
Step 2: Reload and verify compilation
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload)"
Expected: nil.
view/invoice.clj callerOpen src/com/getorcha/app/http/documents/view/invoice.clj and find the invoice-detail-view function (around line 111-159). Locate the call:
;; Header grid: invoice number, dates, and parties (now included in invoice-header)
(app.ui.components/invoice-header structured-data created-at)
Change to:
;; Header grid: invoice number, dates, and parties (now included in invoice-header)
(app.ui.components/invoice-header structured-data created-at
:editable-path-prefix [:structured-data])
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload) (require 'com.getorcha.app.http.documents.view.invoice :reload)"
Expected: nil.
Open an invoice detail view:
psql -h localhost -U postgres -d orcha -t -c "SELECT id FROM document WHERE type = 'invoice' AND status = 'completed' LIMIT 1;"
Copy the UUID, then:
playwright-cli open http://localhost:8080/documents/view/<UUID>
playwright-cli snapshot
In the snapshot, find the Invoice Number field. Hover over it:
playwright-cli hover <ref-id-of-invoice-number>
playwright-cli screenshot --filename=hover.png
Expected: the invoice number has a subtle blue outline and background tint. Click:
playwright-cli click <ref-id-of-invoice-number>
Expected: the number becomes an input with a stronger blue border and focus ring. The text position is unchanged — the characters sit in exactly the same pixels as before. Type a new value, press Enter:
playwright-cli type "INV-9999"
playwright-cli press Enter
Expected: the input vanishes, the new value INV-9999 shows as plain text, and a brief green outline flash animates out. The original number is lost (no persistence — reload wipes it).
Verify Escape cancels:
playwright-cli click <ref-id-of-invoice-number>
playwright-cli type "XYZ"
playwright-cli press Escape
Expected: the original value is restored, no flash.
clj-kondo --lint src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
Expected: errors: 0, warnings: 0.
git add src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
git commit -m "feat(app): make invoice-header fields inline-editable"
value-row supports :editable + wire payment-summaryFiles:
Modify: src/com/getorcha/app/ui/components.clj:749-758 (value-row)
Modify: src/com/getorcha/app/ui/components.clj:761-... (payment-summary — ~14 value-row call sites)
Step 1: Replace value-row
Current (lines 749-758):
(defn ^:private value-row
"Renders a simple row with label and value (no comparison column)."
[{:keys [label value currency negative?]}]
[:div.value-row
[:span.value-label label]
[:span.value-amount
(when value
(if negative?
(str "-" (format-currency value currency))
(format-currency value currency)))]])
Replace with:
(defn ^:private value-row
"Renders a simple row with label and value (no comparison column).
Options:
- :editable - map {:path [...] :type :currency :raw-value <number>}
When present, the numeric portion inside .value-amount becomes
an inline editable via editable-value. The currency code remains
a static sibling span, so the visible layout is unchanged.
`.value-amount` stays as a direct child of `.value-row` to
preserve any `>` combinator CSS rules targeting it."
[{:keys [label value currency negative? editable]}]
[:div.value-row
[:span.value-label label]
[:span.value-amount
(when value
(let [amount-str (if negative?
(str "-" (format-number-with-separator value))
(format-number-with-separator value))
suffix-str (when currency (str " " currency))]
(if editable
(editable-value (:path editable) :currency
{:raw-value value :currency currency}
[:span.currency-wrapper
[:span.amount amount-str]
[:span.currency-suffix suffix-str]])
(format-currency value currency))))]])
Key decision: the .value-amount span stays as a direct child of .value-row. The editable-value wrapper lives inside .value-amount, not around it. This preserves any .value-row > .value-amount direct-child CSS selectors that may exist. The JS (buildCurrencyInput) queries .amount and .currency-suffix by class from within the .editable-value wrapper, so nesting works either way.
.value-row > .value-amount CSS selectors (if any) still matchgrep -n 'value-row *> *\.value-amount\|value-row\.total' resources/app/public/css/style.css
Expected: any such selectors should still find .value-amount as a direct child because we kept it at the same DOM level. If the grep reveals rules like .value-row > .editable-value (which wouldn't exist pre-change), the check is automatically fine.
payment-summary to pass :editable to its value-row callspayment-summary currently (line 761) destructures structured-data and calls value-row many times with hardcoded keys. Add an :editable-path-prefix kwarg and thread it through.
In payment-summary's argument list (currently just [{:keys [...]} :as _structured-data]), add the kwarg:
(defn payment-summary
"Renders the payment summary section with financial totals and payment details.
Shows extracted values from the invoice with a math check badge in the title.
Options:
- :editable-path-prefix - enables inline editing on every row when present."
[{:keys [subtotal discount shipping packaging surcharges tax-rate tax-amount tax-rate-breakdowns
total amount-due currency prepayments validation-results installments line-items
issuer payment-reference payment-terms skonto-percentage skonto-deadline paid-date
incoterm-code incoterm-place freight-included packaging-included
customs-included insurance-included delivery-terms-raw]
:as _structured-data}
& {:keys [editable-path-prefix]}]
Then inside the function, add a helper and thread it into every value-row call:
(let [fee-items (filter #(= "fee" (:category %)) line-items)
{:keys [iban bic bank-name account-name account-number
sort-code routing-number bsb]} issuer
edit (fn [k raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k) :type :currency :raw-value raw}))
;; Aggregate by rate in case extraction returned multiple entries for same rate
;; Sort nil rates (non-taxable) last, then by rate ascending
extracted-breakdown (when (seq tax-rate-breakdowns) ...)
...
Then replace every value-row call with one that passes :editable. Specifically:
;; Single subtotal
(when subtotal
(value-row {:label "Subtotal"
:value subtotal
:currency currency
:editable (edit :subtotal subtotal)}))
;; --- DISCOUNT ---
(when (and discount (not (zero? discount)))
(value-row {:label "Discount"
:value discount
:currency currency
:negative? true
:editable (edit :discount discount)}))
;; --- SHIPPING ---
(when (and shipping (not (zero? shipping)))
(value-row {:label "Shipping"
:value shipping
:currency currency
:editable (edit :shipping shipping)}))
;; --- PACKAGING ---
(when (and packaging (not (zero? packaging)))
(value-row {:label "Packaging"
:value packaging
:currency currency
:editable (edit :packaging packaging)}))
;; --- TAX ROWS ---
;; Single tax (the breakdown rows are derived per-rate; leave them read-only
;; since editing a breakdown row doesn't map to a single key path)
(when (or tax-amount tax-rate)
(value-row {:label (if tax-rate (str "Tax (" tax-rate "%)") "Tax")
:value tax-amount
:currency currency
:editable (edit :tax-amount tax-amount)}))
Intentionally skipped: the per-rate breakdown rows (for [{:keys [rate subtotal]} extracted-breakdown] and for [{:keys [rate tax-amount]} extracted-breakdown]) stay read-only. Each breakdown row is derived from the :tax-rate-breakdowns array and editing individual cells would need array-index paths, which is doable but out of scope for this prototype — the single-breakdown path (when no per-rate split) is editable.
The total row uses raw [:div.value-row.total ...] hiccup, not value-row — make it editable too:
;; --- TOTAL ROW ---
[:div.value-row.total
[:span.value-label "Total"]
[:span.value-amount
(if (and editable-path-prefix total)
(editable-value (conj editable-path-prefix :total) :currency
{:raw-value total :currency currency}
[:span.currency-wrapper
[:span.amount (format-number-with-separator total)]
[:span.currency-suffix (when currency (str " " currency))]])
(or (format-currency total currency) "N/A"))]]
view/invoice.clj callerFind the payment-summary call in invoice-detail-view:
;; Payment summary (merged financial summary and payment details)
(app.ui.components/payment-summary structured-data)]))
Change to:
;; Payment summary (merged financial summary and payment details)
(app.ui.components/payment-summary structured-data
:editable-path-prefix [:structured-data])]))
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload) (require 'com.getorcha.app.http.documents.view.invoice :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
Expected: errors: 0, warnings: 0.
Reload the invoice detail view. Scroll to the Payment Summary section.
playwright-cli reload
playwright-cli snapshot
Find the Subtotal row. Hover:
playwright-cli hover <ref-of-subtotal-amount>
Expected: subtle blue outline appears around the .amount span (not around the EUR suffix). The number stays right-aligned.
Click:
playwright-cli click <ref-of-subtotal-amount>
Expected: the number becomes an input, still right-aligned, EUR suffix stays to the right of the input, in the same pixel position.
Type a new value and press Enter:
playwright-cli type "9999,99"
playwright-cli press Enter
Expected: the new value shows with the EUR suffix, green flash animates out.
Verify that the Total row (bottom) is also editable. It's a separate code path (raw hiccup, not value-row).
git add src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
git commit -m "feat(app): make payment-summary rows inline-editable"
party-card supports :editable-path-prefix (first multi-line textarea)Files:
Modify: src/com/getorcha/app/ui/components.clj:593-645 (party-card)
Modify: src/com/getorcha/app/ui/components.clj:... (invoice-header — restore the :editable-path-prefix wiring disabled in Task 6)
Step 1: Replace party-card
Current (lines 593-645):
(defn party-card
"Renders a party (issuer/recipient) card.
title - card title (e.g., \"Issuer\", \"Recipient\")
party - map with :name, :address, :tax-id-type, :tax-id, :vat-id, :email, :phone, :fax, etc.
For issuers, may also include :matched-account-number, :match-confidence, :match-reasoning"
[title {:keys [name address tax-id-type tax-id vat-id email phone fax contact-person
matched-account-number match-confidence match-reasoning]
:as _party}]
(let [display-tax-id (or tax-id vat-id)
display-tax-label (get tax-id-type-labels tax-id-type "VAT")]
[:div.party-card
[:div.party-card-title title]
(when name
[:div.party-card-name-row
[:div.party-card-name name]
(copy-button name)])
(when address
[:div.party-card-address-row
[:div.party-card-address address]
(copy-button address)])
(when display-tax-id
[:div.party-card-field
[:span.party-card-field-label display-tax-label]
[:div.party-card-field-value-row
[:span.party-card-field-value display-tax-id]
(copy-button display-tax-id)]])
(when contact-person
[:div.party-card-field
[:span.party-card-field-label "Contact"]
[:span.party-card-field-value contact-person]])
(when email
[:div.party-card-field
[:span.party-card-field-label "Email"]
[:div.party-card-field-value-row
[:span.party-card-field-value email]
(copy-button email)]])
(when phone
[:div.party-card-field
[:span.party-card-field-label "Phone"]
[:span.party-card-field-value phone]])
(when fax
[:div.party-card-field
[:span.party-card-field-label "Fax"]
[:span.party-card-field-value fax]])
(when matched-account-number
[:div.party-card-field.supplier-account
[:span.party-card-field-label "Supplier Account"]
[:div.party-card-field-value-row
[:span.party-card-field-value.monospace {:title match-reasoning}
(str matched-account-number)]
(confidence-badge match-confidence)
(copy-button (str matched-account-number))]])]))
Replace with:
(defn party-card
"Renders a party (issuer/recipient) card.
title - card title (e.g., \"Issuer\", \"Recipient\")
party - map with :name, :address, :tax-id-type, :tax-id, :vat-id, :email, :phone, :fax, etc.
For issuers, may also include :matched-account-number, :match-confidence, :match-reasoning
Options:
- :editable-path-prefix - path vector rooted at this party (e.g.
[:structured-data :issuer]). When present, each
scalar field becomes inline-editable. The
confidence badge and tax-id-label dropdown stay
read-only."
[title {:keys [name address tax-id-type tax-id vat-id email phone fax contact-person
matched-account-number match-confidence match-reasoning]
:as _party}
& {:keys [editable-path-prefix]}]
(let [display-tax-id (or tax-id vat-id)
display-tax-label (get tax-id-type-labels tax-id-type "VAT")
edit (fn [k type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k)
:type type
:raw-value raw}))
wrap (fn [edit-opts display]
(if edit-opts
(editable-value (:path edit-opts) (:type edit-opts)
(select-keys edit-opts [:raw-value :currency :options :class])
display)
display))]
[:div.party-card
[:div.party-card-title title]
(when name
[:div.party-card-name-row
(wrap (edit :name :text name)
[:div.party-card-name name])
(copy-button name)])
(when address
[:div.party-card-address-row
(wrap (edit :address :multiline address)
[:div.party-card-address address])
(copy-button address)])
(when display-tax-id
[:div.party-card-field
[:span.party-card-field-label display-tax-label]
[:div.party-card-field-value-row
(wrap (edit (if tax-id :tax-id :vat-id) :text display-tax-id)
[:span.party-card-field-value display-tax-id])
(copy-button display-tax-id)]])
(when contact-person
[:div.party-card-field
[:span.party-card-field-label "Contact"]
(wrap (edit :contact-person :text contact-person)
[:span.party-card-field-value contact-person])])
(when email
[:div.party-card-field
[:span.party-card-field-label "Email"]
[:div.party-card-field-value-row
(wrap (edit :email :text email)
[:span.party-card-field-value email])
(copy-button email)]])
(when phone
[:div.party-card-field
[:span.party-card-field-label "Phone"]
(wrap (edit :phone :text phone)
[:span.party-card-field-value phone])])
(when fax
[:div.party-card-field
[:span.party-card-field-label "Fax"]
(wrap (edit :fax :text fax)
[:span.party-card-field-value fax])])
(when matched-account-number
[:div.party-card-field.supplier-account
[:span.party-card-field-label "Supplier Account"]
[:div.party-card-field-value-row
(wrap (edit :matched-account-number :text matched-account-number)
[:span.party-card-field-value.monospace {:title match-reasoning}
(str matched-account-number)])
(confidence-badge match-confidence)
(copy-button (str matched-account-number))]])]))
:editable-path-prefix wiring in invoice-headerIn the invoice-header function edited in Task 6, restore the party-card calls with the :editable-path-prefix argument (the NOTE comment we left):
Change:
;; Parties grid (issuer and recipient)
;; NOTE: party-card editable-path-prefix wiring added in Task 8.
[:div.parties-grid
(if (seq issuer)
(party-card "Issuer" issuer)
[:div.party-card.empty
[:div.party-card-title "Issuer"]
[:div.empty-placeholder "No issuer information"]])
(if (seq recipient)
(party-card "Recipient" recipient)
[:div.party-card.empty
[:div.party-card-title "Recipient"]
[:div.empty-placeholder "No recipient information"]])]
Back to:
;; Parties grid (issuer and recipient)
[:div.parties-grid
(if (seq issuer)
(party-card "Issuer" issuer
:editable-path-prefix (when editable-path-prefix
(conj editable-path-prefix :issuer)))
[:div.party-card.empty
[:div.party-card-title "Issuer"]
[:div.empty-placeholder "No issuer information"]])
(if (seq recipient)
(party-card "Recipient" recipient
:editable-path-prefix (when editable-path-prefix
(conj editable-path-prefix :recipient)))
[:div.party-card.empty
[:div.party-card-title "Recipient"]
[:div.empty-placeholder "No recipient information"]])]
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj
Expected: errors: 0, warnings: 0.
playwright-cli reload
playwright-cli snapshot
Scroll to the Issuer card. Hover over the name — outline appears. Click, type, Enter. Value updates with green flash.
Now test the multi-line address:
playwright-cli click <ref-of-issuer-address>
Expected: the address becomes a textarea at exactly the same pixel height as the rendered address. Type Shift+Enter to insert a newline:
playwright-cli keydown Shift
playwright-cli press Enter
playwright-cli keyup Shift
playwright-cli type "Extra line"
Expected: the textarea grows by one line. Press Enter alone to save:
playwright-cli press Enter
Expected: saved with green flash. The new multi-line text renders with white-space: pre-line so the newline is preserved.
Then Escape cancel test:
playwright-cli click <ref-of-issuer-address>
playwright-cli type "should disappear"
playwright-cli press Escape
Expected: the original multi-line address returns unchanged.
git add src/com/getorcha/app/ui/components.clj
git commit -m "feat(app): make party-card fields inline-editable (incl. multi-line address)"
line-items-table cells editableFiles:
Modify: src/com/getorcha/app/ui/components.clj:700-730 (line-items-table)
Modify: src/com/getorcha/app/http/documents/view/invoice.clj:... (caller)
Step 1: Replace line-items-table
Current:
(defn line-items-table
"Renders the line items table.
Returns nil if items is empty or nil."
[items currency]
(when (seq items)
(collapsible-section
(str "Line Items (" (count items) ")")
"section-line-items"
[:table.line-items-table
[:thead
[:tr
[:th "Description"]
[:th "Article Code"]
[:th.numeric "Qty"]
[:th "Unit"]
[:th.numeric "Unit Price"]
[:th.numeric "Amount"]]]
[:tbody
(for [{:keys [description article-code quantity unit unit-price price-per amount]} items]
[:tr
[:td (or description "-")]
[:td (or article-code "-")]
[:td.numeric (when quantity (str quantity))]
[:td (or unit "-")]
[:td.numeric (when unit-price
(if (and price-per (> price-per 1))
[:span (format-currency unit-price currency)
[:span.price-per-indicator (str " (per " price-per ")")]]
(format-currency unit-price currency)))]
[:td.numeric (format-currency amount currency)]])]])))
Replace with:
(defn line-items-table
"Renders the line items table.
Returns nil if items is empty or nil.
Options:
- :editable-path-prefix - path vector pointing at :line-items (e.g.
[:structured-data :line-items]). When present,
each scalar cell becomes inline-editable."
[items currency & {:keys [editable-path-prefix]}]
(when (seq items)
(let [edit (fn [idx k type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix idx k)
:type type
:raw-value raw}))
wrap (fn [edit-opts display]
(if edit-opts
(editable-value (:path edit-opts) (:type edit-opts)
(merge {:currency currency}
(select-keys edit-opts [:raw-value :options]))
display)
display))]
(collapsible-section
(str "Line Items (" (count items) ")")
"section-line-items"
[:table.line-items-table
[:thead
[:tr
[:th "Description"]
[:th "Article Code"]
[:th.numeric "Qty"]
[:th "Unit"]
[:th.numeric "Unit Price"]
[:th.numeric "Amount"]]]
[:tbody
(for [[idx {:keys [description article-code quantity unit unit-price price-per amount]}]
(map-indexed vector items)]
^{:key idx}
[:tr
[:td (wrap (edit idx :description :text description)
(or description "-"))]
[:td (wrap (edit idx :article-code :text article-code)
(or article-code "-"))]
[:td.numeric (wrap (edit idx :quantity :number quantity)
(when quantity (str quantity)))]
[:td (wrap (edit idx :unit :text unit)
(or unit "-"))]
[:td.numeric
(if (and price-per (> price-per 1))
;; Composite: editable unit-price + static price-per indicator.
[:span
(wrap (edit idx :unit-price :currency unit-price)
[:span.currency-wrapper
[:span.amount (format-number-with-separator unit-price)]
[:span.currency-suffix (when currency (str " " currency))]])
[:span.price-per-indicator (str " (per " price-per ")")]]
(wrap (edit idx :unit-price :currency unit-price)
[:span.currency-wrapper
[:span.amount (when unit-price (format-number-with-separator unit-price))]
[:span.currency-suffix (when currency (str " " currency))]]))]
[:td.numeric (wrap (edit idx :amount :currency amount)
[:span.currency-wrapper
[:span.amount (when amount (format-number-with-separator amount))]
[:span.currency-suffix (when currency (str " " currency))]])]])]]))))
Note: currency cells now always render the split .amount/.currency-suffix spans even when non-editable — this is a visual change because previously they rendered as a single string. However, since the spans have no padding/border and the text concatenation is identical, the visual output stays pixel-identical. If a screen with currency cells previously showed "1.234,56 EUR" as one text node, it now shows it as two adjacent spans — the rendered glyphs are in exactly the same pixel positions.
view/invoice.clj callerFind in invoice-detail-view:
;; Line items table - enhanced version if accounts/accruals/vat present
(if use-enhanced?
(app.ui.components/enhanced-line-items-table line-items currency)
(app.ui.components/line-items-table line-items currency))
Change to:
;; Line items table - enhanced version if accounts/accruals/vat present
(if use-enhanced?
(app.ui.components/enhanced-line-items-table line-items currency)
(app.ui.components/line-items-table line-items currency
:editable-path-prefix [:structured-data :line-items]))
(enhanced-line-items-table gets wired in Task 10.)
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload) (require 'com.getorcha.app.http.documents.view.invoice :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
Expected: errors: 0, warnings: 0.
Not all invoices use line-items-table (the enhanced card version kicks in if accounts/vat/accruals are present). Find one without enrichments:
psql -h localhost -U postgres -d orcha -t -c "
SELECT id FROM document
WHERE type = 'invoice'
AND status = 'completed'
AND structured_data->'line-items' IS NOT NULL
AND NOT (structured_data->'line-items' @> '[{\"debit_account\": {}}]'::jsonb)
LIMIT 1;"
Open it and verify each cell:
playwright-cli open http://localhost:8080/documents/view/<UUID>
playwright-cli snapshot
Hover over a Quantity cell, click, type a new number, press Enter. Expected: pixel-stable, input accepts number, Enter commits, green flash.
Hover over a Unit Price cell. The currency suffix should stay visible to the right of the amount even when clicking into the amount portion.
git add src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
git commit -m "feat(app): make line-items-table cells inline-editable"
line-item-card cells editable (description, meta-row, amount)Files:
Modify: src/com/getorcha/app/ui/components.clj:1150-1308 (line-item-card)
Modify: src/com/getorcha/app/ui/components.clj:1311-1325 (enhanced-line-items-table)
Modify: src/com/getorcha/app/http/documents/view/invoice.clj (already wired in Task 9 to the table; pass the same prefix here)
Step 1: Replace line-item-card
This task wires up the description, amount, and all meta-row cells (qty/unit, unit-price, tax-rate, discount, article, bu-code). Accounts grid and accrual come in Task 11.
Current (lines 1150-1232, just the parts that emit values):
(defn ^:private line-item-card
"..."
[idx {:keys [description article-code quantity unit unit-price price-per
discount discount-type tax-rate amount surcharges
debit-account credit-account accrual cost-center vat-validation bu-code]
:as _item}
currency]
(let [...]
[:div.line-item-card {:key idx}
;; Row 1: description + amount with small headlines
[:div.line-item-top
[:div.line-item-description
[:span.meta-label "Item"]
[:span.line-item-name (or description "-")]]
[:div.line-item-amount
[:span.meta-label "Amount"]
[:span.line-item-price (format-currency (or amount 0) currency)]]]
;; Row 2: Qty, Unit Price, Tax, Discount, Article, BU Code — flexible flow
[:div.line-item-meta-row
(when quantity
[:div.grid-cell
[:span.meta-label "Qty"]
[:span.meta-value (str quantity (when unit (str " " unit)))]])
...
Add :editable-path-prefix as a kwarg and thread it through via the edit + wrap helper pattern used in previous tasks. Only change what's shown below — keep the rest of the function (reasoning section, etc.) as-is.
Modified function signature and relevant blocks:
(defn ^:private line-item-card
"Renders a single line item as a compact card.
Layout:
Row 1: Description + Amount
Row 2: Qty | Unit Price | Tax (with VAT check inline) | Discount | Article | BU Code
Row 3: Debit Account | Credit Account | Cost Center | Accrual (all as flat meta-items)
Bottom: Single unified reasoning toggle for all enrichments
Options:
- :editable-path-prefix - path vector pointing at this item (e.g.
[:structured-data :line-items 0]). When present,
every scalar in the card becomes inline-editable.
Reasoning sections, confidence badges, and VAT
validation labels stay read-only."
[idx {:keys [description article-code quantity unit unit-price price-per
discount discount-type tax-rate amount surcharges
debit-account credit-account accrual cost-center vat-validation bu-code]
:as _item}
currency
& {:keys [editable-path-prefix]}]
(let [{vat-status :status vat-expected-rate :expected-rate
vat-reasoning :reasoning vat-suggestion :suggestion} vat-validation
bu-code-value (:code bu-code)
bu-code-reasoning (:reasoning bu-code)
rate-mismatch? (and (some? tax-rate) (some? vat-expected-rate)
(not (== tax-rate vat-expected-rate)))
reasoning-sections (cond-> []
(:reasoning debit-account)
(conj {:label "Debit Account" :text (:reasoning debit-account)})
(:reasoning credit-account)
(conj {:label "Credit Account" :text (:reasoning credit-account)})
(:reasoning cost-center)
(conj {:label "Cost Center" :text (:reasoning cost-center)})
(:reasoning accrual)
(conj {:label "Period Allocation" :text (:reasoning accrual)})
(or vat-reasoning vat-suggestion)
(conj {:label "VAT" :text (str vat-reasoning
(when (and vat-reasoning vat-suggestion) "\n")
vat-suggestion)})
bu-code-reasoning
(conj {:label "BU Code" :text bu-code-reasoning}))
edit (fn
([k type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k) :type type :raw-value raw}))
([k sub type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k sub) :type type :raw-value raw})))
wrap (fn [edit-opts display]
(if edit-opts
(editable-value (:path edit-opts) (:type edit-opts)
(merge {:currency currency}
(select-keys edit-opts [:raw-value :options]))
display)
display))]
[:div.line-item-card {:key idx}
;; Row 1: description + amount with small headlines
[:div.line-item-top
[:div.line-item-description
[:span.meta-label "Item"]
(wrap (edit :description :text description)
[:span.line-item-name (or description "-")])]
[:div.line-item-amount
[:span.meta-label "Amount"]
(wrap (edit :amount :currency amount)
[:span.line-item-price
[:span.currency-wrapper
[:span.amount (format-number-with-separator (or amount 0))]
[:span.currency-suffix (when currency (str " " currency))]]])]]
;; Row 2: Qty, Unit Price, Tax, Discount, Article, BU Code
[:div.line-item-meta-row
(when quantity
[:div.grid-cell
[:span.meta-label "Qty"]
[:span.meta-value
(wrap (edit :quantity :number quantity)
(str quantity))
(when unit
[:span " "
(wrap (edit :unit :text unit) unit)])]])
(when unit-price
[:div.grid-cell
[:span.meta-label (if (and price-per (> price-per 1))
(str "Unit Price (per " price-per ")")
"Unit Price")]
[:span.meta-value
(wrap (edit :unit-price :currency unit-price)
[:span.currency-wrapper
[:span.amount (format-number-with-separator unit-price)]
[:span.currency-suffix (when currency (str " " currency))]])]])
(when tax-rate
[:div.grid-cell
[:span.meta-label "Tax"
(when vat-status
(if rate-mismatch?
[:span.vat-status-label.invalid "Invalid"]
[:span.vat-status-label.valid "Correct"]))]
[:span.meta-value
(wrap (edit :tax-rate :number tax-rate)
(str tax-rate "%"))
(when rate-mismatch?
[:span.vat-expected-inline (str " (exp. " vat-expected-rate "%)")])]])
(when (and discount (not (zero? discount)))
[:div.grid-cell
[:span.meta-label "Discount"]
[:span.meta-value.discount
(wrap (edit :discount (if (= discount-type "percentage") :number :currency) discount)
(str "-" (if (= discount-type "percentage")
(str discount "%")
(format-currency discount currency))))]])
(when article-code
[:div.grid-cell
[:span.meta-label "Article"]
[:span.meta-value.monospace
(wrap (edit :article-code :text article-code)
article-code)]])
(when bu-code-value
[:div.grid-cell
[:span.meta-label "BU Code"]
[:span.meta-value.monospace
(wrap (edit :bu-code :code :text bu-code-value)
bu-code-value)]])]
;; Row 3: Accounts, Cost Center, Period Allocation — 2-column full-width grid
;; Wiring added in Task 11.
(when (or debit-account credit-account cost-center accrual)
[:div.line-item-accounts-grid
(when debit-account
[:div.grid-cell
[:span.meta-label "Debit Account"
(confidence-pct (:confidence debit-account))]
[:span.meta-value
[:span.account-number (:number debit-account)]
" "
[:span.account-name (:name debit-account)]]])
(when credit-account
[:div.grid-cell
[:span.meta-label "Credit Account"
(confidence-pct (:confidence credit-account))]
[:span.meta-value
[:span.account-number (:number credit-account)]
" "
[:span.account-name (:name credit-account)]]])
(when cost-center
[:div.grid-cell
[:span.meta-label "Cost Center"
(confidence-pct (:confidence cost-center))]
[:span.meta-value
[:span.account-number (:number cost-center)]
" "
[:span.account-name (:name cost-center)]
(when (:employee cost-center)
[:span.cost-center-employee (str " — " (:employee cost-center))])]])
(when accrual
(let [{:keys [accrual-type periods confidence]} accrual
type-label (get accrual-type-labels accrual-type (str accrual-type))
period-count (count periods)]
[:div.grid-cell
[:span.meta-label "Period Allocation"
(confidence-pct confidence)]
[:span.meta-value
[:span {:class (str "accrual-type-badge " (name accrual-type))} type-label]
" "
(if (= 1 period-count)
[:span.accrual-single-period (period-label (:period (first periods)))]
[:span.accrual-period-count (str period-count " periods")])
(when (> period-count 1)
[:span.accrual-periods-inline
(for [{:keys [period amount]} (take 4 periods)]
[:span.period-chip {:key period}
[:span.period-label (period-label period)]
[:span.period-amount (format-currency amount nil)]])
(when (> period-count 4)
[:span.period-chip.more (str "+" (- period-count 4) " more")])])]]))])
;; Surcharges row (when present)
(when (seq surcharges)
[:div.line-item-surcharges
[:span.surcharges-label "Surcharges:"]
(for [{s-desc :description s-amount :amount s-tax-code :tax-code} surcharges]
[:span.surcharge-item {:key s-desc}
[:span.surcharge-desc s-desc]
[:span.surcharge-amount (format-currency s-amount currency)]
(when s-tax-code
[:span.surcharge-tax-code (str "(" s-tax-code ")")])])])
;; Unified reasoning toggle — single toggle for all enrichment reasoning
(when (seq reasoning-sections)
[:div.reasoning-container
[:button.reasoning-toggle
{:onclick (str "var el = document.getElementById('combined-reasoning-" idx "'); "
"el.classList.toggle('expanded'); "
"this.textContent = el.classList.contains('expanded') ? 'Hide reasoning' : 'Show reasoning';")}
"Show reasoning"]
[:div.reasoning-text {:id (str "combined-reasoning-" idx)}
(for [{:keys [label text]} reasoning-sections]
[:div {:key label}
[:strong (str label ": ")]
[:span text]])]])]))
Note: the bu-code field stores its value under :code (a sub-map: {:code "..." :reasoning "..."}). The 4-arity overload of edit builds the nested path: (edit :bu-code :code :text bu-code-value) gives k=:bu-code, sub=:code, type=:text, raw=bu-code-value, and the path becomes (conj prefix :bu-code :code).
enhanced-line-items-table to pass through the prefixCurrent (line ~1311):
(defn enhanced-line-items-table
"Line items as card stack with full details including debit/credit accounts and accrual.
items - line items with :debit-account, :credit-account and :accrual data
currency - currency code for formatting"
[items currency]
(when (seq items)
...
(for [[idx item] (map-indexed vector items)]
(line-item-card idx item currency))))
Add :editable-path-prefix kwarg and thread it through. Read the full existing function first (let me show the canonical replacement for the parts the plan touches — keep the surrounding collapsible-section wrapper unchanged):
Change the signature and the line-item-card invocation:
(defn enhanced-line-items-table
"Line items as card stack with full details including debit/credit accounts and accrual.
items - line items with :debit-account, :credit-account and :accrual data
currency - currency code for formatting
Options:
- :editable-path-prefix - path vector pointing at :line-items (e.g.
[:structured-data :line-items]). When present,
each card's scalar fields become inline-editable."
[items currency & {:keys [editable-path-prefix]}]
(when (seq items)
;; ... keep existing collapsible-section wrapper / header ...
;; In the (for [[idx item] (map-indexed vector items)] ...) body:
(for [[idx item] (map-indexed vector items)]
(line-item-card idx item currency
:editable-path-prefix (when editable-path-prefix
(conj editable-path-prefix idx))))))
Read the existing body to see the exact structure around the for before editing. Use:
clj-nrepl-eval -p <PORT> "(require 'clojure.repl) (clojure.repl/source com.getorcha.app.ui.components/enhanced-line-items-table)"
Or open the file directly and update the single line-item-card call, adding the kwarg after currency.
view/invoice.clj callerFind:
;; Line items table - enhanced version if accounts/accruals/vat present
(if use-enhanced?
(app.ui.components/enhanced-line-items-table line-items currency)
(app.ui.components/line-items-table line-items currency
:editable-path-prefix [:structured-data :line-items]))
Change to:
;; Line items table - enhanced version if accounts/accruals/vat present
(if use-enhanced?
(app.ui.components/enhanced-line-items-table line-items currency
:editable-path-prefix [:structured-data :line-items])
(app.ui.components/line-items-table line-items currency
:editable-path-prefix [:structured-data :line-items]))
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload) (require 'com.getorcha.app.http.documents.view.invoice :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
Expected: errors: 0, warnings: 0.
Find an invoice that uses line-item-card (has account enrichments):
psql -h localhost -U postgres -d orcha -t -c "
SELECT id FROM document
WHERE type = 'invoice'
AND status = 'completed'
AND jsonb_path_exists(structured_data, '$.\"line-items\"[*].\"debit-account\"')
LIMIT 1;"
Open it in playwright-cli, snapshot, and verify each meta-row cell is hoverable/clickable/editable:
The accounts grid (Row 3) is still read-only — that's Task 11.
git add src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
git commit -m "feat(app): make line-item-card meta cells inline-editable"
line-item-card accounts grid editableFiles:
Modify: src/com/getorcha/app/ui/components.clj:1150-... (line-item-card, Row 3 block only)
Step 1: Replace Row 3 (accounts grid) in line-item-card
Find the Row 3 block in line-item-card (edited in Task 10):
;; Row 3: Accounts, Cost Center, Period Allocation — 2-column full-width grid
;; Wiring added in Task 11.
(when (or debit-account credit-account cost-center accrual)
[:div.line-item-accounts-grid
(when debit-account
[:div.grid-cell
[:span.meta-label "Debit Account"
(confidence-pct (:confidence debit-account))]
[:span.meta-value
[:span.account-number (:number debit-account)]
" "
[:span.account-name (:name debit-account)]]])
(when credit-account
[:div.grid-cell
[:span.meta-label "Credit Account"
(confidence-pct (:confidence credit-account))]
[:span.meta-value
[:span.account-number (:number credit-account)]
" "
[:span.account-name (:name credit-account)]]])
(when cost-center
[:div.grid-cell
[:span.meta-label "Cost Center"
(confidence-pct (:confidence cost-center))]
[:span.meta-value
[:span.account-number (:number cost-center)]
" "
[:span.account-name (:name cost-center)]
(when (:employee cost-center)
[:span.cost-center-employee (str " — " (:employee cost-center))])]])
(when accrual
(let [{:keys [accrual-type periods confidence]} accrual
type-label (get accrual-type-labels accrual-type (str accrual-type))
period-count (count periods)]
[:div.grid-cell
[:span.meta-label "Period Allocation"
(confidence-pct confidence)]
[:span.meta-value
[:span {:class (str "accrual-type-badge " (name accrual-type))} type-label]
" "
(if (= 1 period-count)
[:span.accrual-single-period (period-label (:period (first periods)))]
[:span.accrual-period-count (str period-count " periods")])
(when (> period-count 1)
[:span.accrual-periods-inline
(for [{:keys [period amount]} (take 4 periods)]
[:span.period-chip {:key period}
[:span.period-label (period-label period)]
[:span.period-amount (format-currency amount nil)]])
(when (> period-count 4)
[:span.period-chip.more (str "+" (- period-count 4) " more")])])]]))])
Replace with (each account's number + name become independently editable; confidence and accrual chips stay read-only):
;; Row 3: Accounts, Cost Center, Period Allocation — 2-column full-width grid
(when (or debit-account credit-account cost-center accrual)
[:div.line-item-accounts-grid
(when debit-account
[:div.grid-cell
[:span.meta-label "Debit Account"
(confidence-pct (:confidence debit-account))]
[:span.meta-value
(wrap (edit :debit-account :number :text (:number debit-account))
[:span.account-number (:number debit-account)])
" "
(wrap (edit :debit-account :name :text (:name debit-account))
[:span.account-name (:name debit-account)])]])
(when credit-account
[:div.grid-cell
[:span.meta-label "Credit Account"
(confidence-pct (:confidence credit-account))]
[:span.meta-value
(wrap (edit :credit-account :number :text (:number credit-account))
[:span.account-number (:number credit-account)])
" "
(wrap (edit :credit-account :name :text (:name credit-account))
[:span.account-name (:name credit-account)])]])
(when cost-center
[:div.grid-cell
[:span.meta-label "Cost Center"
(confidence-pct (:confidence cost-center))]
[:span.meta-value
(wrap (edit :cost-center :number :text (:number cost-center))
[:span.account-number (:number cost-center)])
" "
(wrap (edit :cost-center :name :text (:name cost-center))
[:span.account-name (:name cost-center)])
(when (:employee cost-center)
[:span.cost-center-employee
(str " — ")
(wrap (edit :cost-center :employee :text (:employee cost-center))
(:employee cost-center))])]])
(when accrual
(let [{:keys [accrual-type periods confidence]} accrual
type-label (get accrual-type-labels accrual-type (str accrual-type))
period-count (count periods)]
[:div.grid-cell
[:span.meta-label "Period Allocation"
(confidence-pct confidence)]
[:span.meta-value
;; Accrual type stays read-only in this task (enum wiring in Task 12).
[:span {:class (str "accrual-type-badge " (name accrual-type))} type-label]
" "
(if (= 1 period-count)
[:span.accrual-single-period (period-label (:period (first periods)))]
[:span.accrual-period-count (str period-count " periods")])
(when (> period-count 1)
[:span.accrual-periods-inline
(for [{:keys [period amount]} (take 4 periods)]
[:span.period-chip {:key period}
[:span.period-label (period-label period)]
[:span.period-amount (format-currency amount nil)]])
(when (> period-count 4)
[:span.period-chip.more (str "+" (- period-count 4) " more")])])]]))])
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj
Expected: errors: 0, warnings: 0.
Open an invoice with enriched line items. On a line item's debit account:
playwright-cli hover <ref-of-debit-account-number>
Expected: only the number gets the outline, not the name.
playwright-cli click <ref-of-debit-account-number>
playwright-cli type "9999"
playwright-cli press Enter
Expected: the number changes, the name and confidence badge are unchanged.
Repeat for the name:
playwright-cli click <ref-of-debit-account-name>
playwright-cli type "Test Account"
playwright-cli press Enter
Expected: the name changes, the new number (from the previous step) and confidence are unchanged.
git add src/com/getorcha/app/ui/components.clj
git commit -m "feat(app): make line-item account fields inline-editable"
delivery-section + enum fields (incoterm-code, accrual-type)Files:
src/com/getorcha/app/ui/components.clj:1118-1147 (delivery-section)src/com/getorcha/app/ui/components.clj:1150-... (line-item-card accrual-type)src/com/getorcha/app/http/documents/view/invoice.clj (add delivery section caller opts)This task covers the highest-risk case: enum fields rendered as badges. The JS side already implements buildEnumSelect (Task 3) which overlays a transparent <select> over the badge. This task wires up the hiccup.
Add these private defs near the top of components.clj (just below tax-id-type-labels at line 582-590 is a reasonable location):
(def ^:private incoterm-2020-codes
"Incoterms 2020 — the 11 official three-letter codes."
["EXW" "FCA" "FAS" "FOB" "CPT" "CIP" "CFR" "CIF" "DAP" "DPU" "DDP"])
(def ^:private accrual-type-options
"Valid accrual types for line items. Keep in sync with accrual-type-labels."
["prepaid-expense" "accrued-expense" "deferred-revenue" "accrued-revenue" "none"])
(If accrual-type-labels is defined elsewhere with different keys, use those keys' names instead.)
delivery-sectionCurrent:
(defn delivery-section
"Collapsible section displaying shipping/delivery information. ..."
[{:keys [shipping-address shipping-country service-address incoterm-code incoterm-place]
:as _structured-data}]
(when (or shipping-address service-address incoterm-code)
(collapsible-section
"Delivery"
"section-delivery"
[:div.delivery-content
(when shipping-address
[:div.delivery-field
[:span.delivery-label "Ship to"]
[:div.delivery-value shipping-address]
(when shipping-country
[:span.country-badge shipping-country])])
(when service-address
[:div.delivery-field
[:span.delivery-label "Service address"]
[:div.delivery-value service-address]])
(when incoterm-code
[:div.delivery-field
[:span.delivery-label "Incoterm"]
[:span.incoterm-badge incoterm-code]
(when incoterm-place
[:span.incoterm-place incoterm-place])])])))
Replace with:
(defn delivery-section
"Collapsible section displaying shipping/delivery information.
Shows shipping destination when different from billing, plus incoterms.
structured-data - full structured data containing :shipping-address, :shipping-country,
:incoterm-code, :incoterm-place
Options:
- :editable-path-prefix - path vector enabling inline editing on all fields.
shipping-country is left read-only (250+ country codes
is too many for a dropdown; typing a free-form country
is acceptable, so it uses :text)."
[{:keys [shipping-address shipping-country service-address incoterm-code incoterm-place]
:as _structured-data}
& {:keys [editable-path-prefix]}]
(when (or shipping-address service-address incoterm-code)
(let [edit (fn [k type raw & [more]]
(when editable-path-prefix
(merge {:path (conj editable-path-prefix k) :type type :raw-value raw}
more)))
wrap (fn [edit-opts display]
(if edit-opts
(let [base-class "editable-value"]
(editable-value (:path edit-opts) (:type edit-opts)
(select-keys edit-opts [:raw-value :currency :options :class])
display))
display))]
(collapsible-section
"Delivery"
"section-delivery"
[:div.delivery-content
(when shipping-address
[:div.delivery-field
[:span.delivery-label "Ship to"]
(wrap (edit :shipping-address :multiline shipping-address)
[:div.delivery-value shipping-address])
(when shipping-country
(wrap (edit :shipping-country :text shipping-country)
[:span.country-badge shipping-country]))])
(when service-address
[:div.delivery-field
[:span.delivery-label "Service address"]
(wrap (edit :service-address :multiline service-address)
[:div.delivery-value service-address])])
(when incoterm-code
[:div.delivery-field
[:span.delivery-label "Incoterm"]
(wrap (edit :incoterm-code :enum incoterm-code {:options incoterm-2020-codes})
[:span.incoterm-badge.enum-wrapper incoterm-code])
(when incoterm-place
(wrap (edit :incoterm-place :text incoterm-place)
[:span.incoterm-place incoterm-place])))])))))
Note the enum-wrapper class added to the editable-value wrapper (via :class opt on editable-value — supported since Task 1). The CSS rule .editable-value.enum-wrapper > select from Task 2 kicks in to position the select absolutely over the badge.
delivery-section incoterm wrap to pass the class (when incoterm-code
[:div.delivery-field
[:span.delivery-label "Incoterm"]
(wrap (edit :incoterm-code :enum incoterm-code
{:options incoterm-2020-codes :class "enum-wrapper"})
[:span.incoterm-badge incoterm-code])
(when incoterm-place
(wrap (edit :incoterm-place :text incoterm-place)
[:span.incoterm-place incoterm-place])))])
Update the local edit helper in this function to merge the extra opts (:class, :options) into the returned map:
edit (fn [k type raw & [extra]]
(when editable-path-prefix
(merge {:path (conj editable-path-prefix k) :type type :raw-value raw}
extra)))
And update the wrap helper to pass :class through:
wrap (fn [edit-opts display]
(if edit-opts
(editable-value (:path edit-opts) (:type edit-opts)
(select-keys edit-opts [:raw-value :currency :options :class])
display)
display))
The select-keys [:raw-value :currency :options :class] list is the canonical set used by every wrap helper in the codebase (Tasks 5, 8, 9, 10 all use it). Nothing else needs updating.
accrual-type in line-item-cardFind the accrual block in line-item-card (edited in Task 11):
(when accrual
(let [{:keys [accrual-type periods confidence]} accrual
type-label (get accrual-type-labels accrual-type (str accrual-type))
period-count (count periods)]
[:div.grid-cell
[:span.meta-label "Period Allocation"
(confidence-pct confidence)]
[:span.meta-value
;; Accrual type stays read-only in this task (enum wiring in Task 12).
[:span {:class (str "accrual-type-badge " (name accrual-type))} type-label]
" "
...
Replace the accrual-type badge line with:
(wrap (edit :accrual :accrual-type :enum (name accrual-type)
{:options accrual-type-options :class "enum-wrapper"})
[:span {:class (str "accrual-type-badge " (name accrual-type))} type-label])
Note: edit here is the 4-arg form: (edit k sub type raw extra?) — except the existing 4-arg form only takes (k sub type raw). Extend it:
In line-item-card's let block (replace the current edit helper):
edit (fn
([k type raw] (edit k type raw nil))
([k type raw extra]
(when editable-path-prefix
(merge {:path (conj editable-path-prefix k) :type type :raw-value raw}
extra)))
([k sub type raw] (edit k sub type raw nil))
([k sub type raw extra]
(when editable-path-prefix
(merge {:path (conj editable-path-prefix k sub) :type type :raw-value raw}
extra))))
Clojure doesn't allow recursive self-reference within fn/let by default — the inner (edit ...) calls won't work. Either define as a named letfn or duplicate the logic:
edit (fn
([k type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k) :type type :raw-value raw}))
([k type raw extra]
(when editable-path-prefix
(merge {:path (conj editable-path-prefix k) :type type :raw-value raw}
extra)))
([k sub type raw]
(when editable-path-prefix
{:path (conj editable-path-prefix k sub) :type type :raw-value raw}))
([k sub type raw extra]
(when editable-path-prefix
(merge {:path (conj editable-path-prefix k sub) :type type :raw-value raw}
extra))))
This gives 4 arities: (k type raw), (k type raw extra), (k sub type raw), (k sub type raw extra).
view/invoice.clj to pass editable-path-prefix to delivery-sectionFind:
;; Delivery section (shipping address, incoterms)
(when has-delivery?
(app.ui.components/delivery-section structured-data))
Change to:
;; Delivery section (shipping address, incoterms)
(when has-delivery?
(app.ui.components/delivery-section structured-data
:editable-path-prefix [:structured-data]))
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.ui.components :reload) (require 'com.getorcha.app.http.documents.view.invoice :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
Expected: errors: 0, warnings: 0.
Find an invoice with incoterm-code:
psql -h localhost -U postgres -d orcha -t -c "
SELECT id FROM document
WHERE type = 'invoice'
AND structured_data->>'incoterm-code' IS NOT NULL
LIMIT 1;"
Open it, scroll to the Delivery section, hover over the incoterm badge:
playwright-cli open http://localhost:8080/documents/view/<UUID>
playwright-cli snapshot
playwright-cli hover <ref-of-incoterm-badge>
Expected: the badge shows the hover outline. Click:
playwright-cli click <ref-of-incoterm-badge>
Expected: the OS-native select dropdown opens. Pick a different code:
playwright-cli select <ref-of-incoterm-select> "DDP"
Expected: the badge updates to show "DDP" and the green save flash animates.
Verify the badge text styling is intact (the transparent select overlay doesn't break the badge's visual appearance).
For accrual-type, find an invoice with enriched line items including accruals and test similarly.
git add src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view/invoice.clj
git commit -m "feat(app): make delivery enum fields and accrual-type inline-editable"
Files:
src/com/getorcha/app/http/documents/view/purchase_order.cljsrc/com/getorcha/app/http/documents/view/goods_received_note.cljsrc/com/getorcha/app/http/documents/view/contract.cljsrc/com/getorcha/app/http/documents/view/notice.cljEach doc type view file composes the same shared components. The changes are mechanical — thread :editable-path-prefix [:structured-data] through every call to invoice-header, line-items-table, enhanced-line-items-table, payment-summary, delivery-section.
For each file, run:
grep -n "invoice-header\|line-items-table\|enhanced-line-items-table\|payment-summary\|delivery-section" src/com/getorcha/app/http/documents/view/purchase_order.clj
Repeat for goods_received_note.clj, contract.clj, notice.clj.
Expected: each file has at most a handful of call sites. Note the line numbers.
For each matching call, add :editable-path-prefix [:structured-data]. Examples:
Before:
(app.ui.components/invoice-header structured-data created-at)
After:
(app.ui.components/invoice-header structured-data created-at
:editable-path-prefix [:structured-data])
Before:
(app.ui.components/line-items-table line-items currency)
After:
(app.ui.components/line-items-table line-items currency
:editable-path-prefix [:structured-data :line-items])
Before:
(app.ui.components/payment-summary structured-data)
After:
(app.ui.components/payment-summary structured-data
:editable-path-prefix [:structured-data])
Before:
(app.ui.components/delivery-section structured-data)
After:
(app.ui.components/delivery-section structured-data
:editable-path-prefix [:structured-data])
Repeat for each of the four doc type files. Some may not call some functions (e.g. notice may have no line items); only update what's actually present.
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.http.documents.view.purchase-order :reload) (require 'com.getorcha.app.http.documents.view.goods-received-note :reload) (require 'com.getorcha.app.http.documents.view.contract :reload) (require 'com.getorcha.app.http.documents.view.notice :reload)"
Expected: nil.
clj-kondo --lint src/com/getorcha/app/http/documents/view/purchase_order.clj src/com/getorcha/app/http/documents/view/goods_received_note.clj src/com/getorcha/app/http/documents/view/contract.clj src/com/getorcha/app/http/documents/view/notice.clj
Expected: errors: 0, warnings: 0.
For each doc type, find a completed document and verify at least one field becomes editable on hover:
# Purchase order
psql -h localhost -U postgres -d orcha -t -c "SELECT id FROM document WHERE type = 'purchase-order' AND status = 'completed' LIMIT 1;"
playwright-cli open http://localhost:8080/documents/view/<UUID>
playwright-cli snapshot
# Hover over one labeled-field value — expect outline.
# Goods received note
psql -h localhost -U postgres -d orcha -t -c "SELECT id FROM document WHERE type = 'goods-received-note' AND status = 'completed' LIMIT 1;"
# ... same
# Contract
psql -h localhost -U postgres -d orcha -t -c "SELECT id FROM document WHERE type = 'contract' AND status = 'completed' LIMIT 1;"
# ... same
# Notice
psql -h localhost -U postgres -d orcha -t -c "SELECT id FROM document WHERE type = 'financial-notice' AND status = 'completed' LIMIT 1;"
# ... same
git add src/com/getorcha/app/http/documents/view/purchase_order.clj src/com/getorcha/app/http/documents/view/goods_received_note.clj src/com/getorcha/app/http/documents/view/contract.clj src/com/getorcha/app/http/documents/view/notice.clj
git commit -m "feat(app): enable inline editing on all doc type views"
Files: none modified
This is a dedicated verification task with no code changes. It confirms the critical rule: the rest state must be pixel-identical to before the feature was added.
Temporarily disable the feature. Open src/com/getorcha/app/http/documents/view/shared.clj and comment out the script tag added in Task 4:
[:div#document-content.detail-container
;; Client runtime for inline editing of structured-data values.
;; (Temporarily disabled for baseline screenshot.)
;; [:script {:src (str "/public/js/editable-fields.js?v=" app.ui.layout/assets-version)
;; :defer true}]
;; Left panel: PDF viewer
Reload:
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.http.documents.view.shared :reload)"
Open a well-populated invoice detail view (one with enhanced line items + delivery + parties):
playwright-cli open http://localhost:8080/documents/view/<UUID>
playwright-cli resize 1440 900
playwright-cli screenshot --filename=baseline-rest.png
Uncomment the script tag in shared.clj. Reload:
clj-nrepl-eval -p <PORT> "(require 'com.getorcha.app.http.documents.view.shared :reload)"
Reload the browser page (without hovering over anything):
playwright-cli reload
playwright-cli screenshot --filename=with-editing-rest.png
compare -metric AE baseline-rest.png with-editing-rest.png diff.png
Expected: output of 0 or a very small number (under 100 pixels, caused only by subpixel font rendering noise which is normal). If the number is large, visually inspect diff.png to find shifted elements, and fix the responsible CSS/hiccup.
If compare is not available, do a visual side-by-side check in the browser: open both screenshots in separate tabs and flip between them. No shift should be perceptible.
Run through each field type on the same document:
playwright-cli snapshot
For each listed field, hover → click → type → Enter and visually confirm:
:text):date):date):text):multiline — Shift+Enter adds a newline, Enter saves):text):text):number):currency — suffix stays, amount portion edits):number — with vat validation badge remaining read-only beside it):text — confidence badge remains read-only):text):currency):currency — raw div path):enum — native dropdown opens over the badge):enum)For each, verify:
On any text field:
playwright-cli click <ref>
playwright-cli type "cancel me"
playwright-cli press Escape
Expected: original value restored, no flash.
playwright-cli click <ref>
playwright-cli type "tab exits"
playwright-cli press Tab
Expected: saved, no focus jump to another editable field, no flash restart.
On a multi-line address:
playwright-cli click <ref>
playwright-cli type "line one"
playwright-cli keydown Shift
playwright-cli press Enter
playwright-cli keyup Shift
playwright-cli type "line two"
playwright-cli press Enter
Expected: both lines visible, save flash.
On a line item with debit-account + credit-account + confidence badges:
playwright-cli click <ref-of-debit-account-number>
playwright-cli type "9999"
playwright-cli press Enter
Expected: number updated; debit account name and confidence badge unchanged; credit account untouched.
Verification task — nothing to commit. Report any issues found and fix them in a follow-up commit before declaring the feature complete.
This plan adds opt-in inline editing to every structured-data value in the document detail view through a single JS runtime and minimal hiccup changes. Task 1-4 build the foundation (helper, CSS, JS, script tag). Tasks 5-12 wire up editing through each display component in rollout order (simplest → most complex). Task 13 generalises to all doc type views. Task 14 is the dedicated pixel-stability verification.
The critical contract — pixel-identical rest state — is enforced by:
editable-value being a bare <span> with no layout-affecting properties.outline, background-color, and box-shadow for state changes.all: unset + inheritance so typed text sits at the same pixel position as display text..amount + .currency-suffix spans so only the number portion becomes editable.<select> overlay so the badge's visual chrome stays put.The feature is strictly additive: callers that don't pass :editable or :editable-path-prefix see no change. Every task except Task 14 ends in a commit so progress is incremental and revertible per-component.