refactor(ssr): revert hiccup→Selmer migration; render forms in Hiccup again

Abandons the Selmer-templating step of the SSR re-authoring and moves the four
migrated form/wizard modals back to Hiccup (com/* components), keeping the
whole-form HTMX swap doctrine, top-rooted render functions, and the
session-backed wizard engine unchanged.

- transaction/edit, transaction/bulk_code, invoices (bulk-edit group), and
  pos/sales_summaries render via com/* again; every hx-* swap (whole-form +
  targeted location-cell / totals-tbody / inline account-cell swaps) is
  preserved exactly.
- add com/single-modal-card to centralize the md:w-[950px] md:h-[650px] modal
  chrome that previously lived only in the Selmer modal-card templates.
- delete auto-ap.ssr.selmer, auto-ap.ssr.components.selmer, selmer_test, the
  whole resources/templates tree (55 files), the selmer dependency, and the
  tailwind resources/templates content glob.
- strip Selmer guidance from the ssr-form-migration skill + modernization plan.

Verified: all four namespaces compile and render with no stringified-hiccup
leaks; output.css rebuilds byte-identically (no Tailwind class loss); 60 e2e
specs pass — the four reverted modals (incl. whole-form-swap focus/caret tests)
plus the untouched wizard/pay/new/rule modals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 00:37:21 -07:00
parent e1a2f7b638
commit 8b43017d6e
71 changed files with 746 additions and 1793 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: ssr-form-migration name: ssr-form-migration
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery. description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, and the data-driven session-backed wizard engine. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery. Rendering stays in Hiccup (`com/*` components) — the earlier Selmer-templating step was abandoned.
--- ---
# SSR Form & Wizard Migration # SSR Form & Wizard Migration
@@ -12,7 +12,7 @@ approach with **zero out-of-band swaps**. Every migration *reads this skill firs
*extends it last* (the Growth contract below). If migration N+1 is not easier than N, *extends it last* (the Growth contract below). If migration N+1 is not easier than N,
the skill-update step was skipped — treat that as a bug. the skill-update step was skipped — treat that as a bug.
The four patterns every migration moves code toward live in `reference/`: The three patterns every migration moves code toward live in `reference/`:
- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus - `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus
invariant + Alpine-survives-swap hardening + target-selector strategy. invariant + Alpine-survives-swap hardening + target-selector strategy.
@@ -20,8 +20,11 @@ The four patterns every migration moves code toward live in `reference/`:
**or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins. **or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins.
- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven - `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven
engine with **per-step state in the Ring session** (the Django `formtools` model). engine with **per-step state in the Ring session** (the Django `formtools` model).
- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the
Hiccup↔Selmer interop bridge, include/block patterns. > **Rendering stays in Hiccup.** An earlier iteration of this skill added a fourth
> pattern — templating interactive components in Selmer — which was later **abandoned**.
> All modals render through the shared Hiccup components (`com/*`); there is no Selmer
> layer. Ignore any residual Selmer references in the cookbooks below.
Growing cookbooks (append every migration): Growing cookbooks (append every migration):
`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`. `component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`.
@@ -65,9 +68,9 @@ Run this loop for each modal. The phase notes in the migration plan list only wh
and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position
(heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not. (heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not.
6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is 6. **Render in Hiccup** with the shared `com/*` components. Reuse cookbook bits; add new
interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8). ones back (heuristic 5). (An earlier version of this step templated interactive
Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2). components in Selmer; that was abandoned — everything renders through Hiccup.)
7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus 7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus
invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7). invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7).

View File

@@ -1,5 +1,8 @@
# Component cookbook # Component cookbook
> **Note:** Selmer was abandoned — all rendering is Hiccup (`com/*`). Ignore any
> Selmer/template/`sc/*` references below; use the equivalent `com/*` Hiccup component.
GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical
snippet. Reuse these before writing anything new; the success signal is *more reuse each snippet. Reuse these before writing anything new; the success signal is *more reuse each
migration*. migration*.

View File

@@ -1,5 +1,8 @@
# Quality scorecard (the ratchet) # Quality scorecard (the ratchet)
> **Note:** Selmer was abandoned — rendering is Hiccup (`com/*`). Ignore the Selmer-specific
> heuristics/mentions below; the swap-doctrine, render-function, and engine ratchets still apply.
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded **before/after each Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded **before/after each
migration** in the commit message and in the results table below. **No metric may regress migration** in the commit message and in the results table below. **No metric may regress
for the touched modal** without a written exception in `gotchas.md`. These are directional for the touched modal** without a written exception in `gotchas.md`. These are directional

View File

@@ -1,148 +0,0 @@
# Selmer template conventions
> **Validated** in the Transaction Edit migration: `location-select*` now renders from
> `resources/templates/components/location-select.html` via the interop bridge, embedded
> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
> Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
## Why Selmer for interactive components
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
the same file — there's no rule a reader (or an LLM) can rely on. The real
`com/typeahead-` mixes them in one map:
```clojure
:x-modelable "value.value" ; keyword key
"x-ref" "hidden" ; string key
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
:x-init "..." ; structural attrs are keywords
```
In a Selmer template the same markup is unambiguous plain HTML:
```html
{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
<a class="{{ classes }}" x-ref="input" tabindex="0"
@keydown.down.prevent.stop="tippy?.show()"
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
<span x-text="value.label"></span>
</a>
</div>
```
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
the Alpine-survives-swap requirement.
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
```clojure
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
(sel/render-str template ctx) ; render from a string (tests/REPL)
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
```
The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
## The worked example — `location-select*`
Template (`resources/templates/components/location-select.html`): plain HTML, an
`{% for %}` over option maps, `{% if opt.selected %}`.
```clojure
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
(defn location-select* [{:keys [name client-locations value ...]}]
(let [options (cond ...) ; [[value label] ...]
selected (or value (ffirst options))
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
(sel/render->hiccup "templates/components/location-select.html"
{:name name :classes classes
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
```
Lessons:
- **Pass computed values in, don't hard-code.** Reuse the Clojure source of truth
(`inputs/default-input-classes`) as a context value rather than copying class strings
into the template — otherwise styling drifts from the shared components.
- **Verify by string-match + e2e, not byte-parity.** `hh/add-class` is set-based, so class
*order* differs from the old `com/select` output; CSS is order-independent and the e2e
proves behavior. (`testing-conventions`: don't assert on exact markup.)
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
## Composition — verified mechanics (selmer 1.12.61)
Proven by REPL before the full migration (do the same before relying on any of these):
- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
include/extends/block are *parse-stage* tags. Rendering a template **string** that
contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
nil handler). So includes only work from a `.html` file, never from `render-str`.
- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
rows — though Clojure-composing the rows (below) is usually simpler.
- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
(works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
Because `{% include with %}` can't pass args and the server computes most values anyway,
each shared component is a **thin Clojure wrapper that renders its own partial** (the
proven `location-select*` shape, generalised). The element *structure* lives 100% in the
`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
```clojure
(sc/hidden {:name :value }) ; -> render "components/hidden.html"
(sc/validated-field {:label :errors } body)
(sc/typeahead {:name :url :value :content-fn }) ; resolves label server-side
(sc/data-grid {:headers [] :footer-tbody } rows)
```
### `attrs->str` — the dynamic-attribute bridge
HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
`{{ attrs|safe }}`: `<input type="hidden"{{ attrs|safe }}>`. Rules mirror hiccup2 — nil/false
dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
`x-data` and `x-init` quotes become `&quot;`/`&apos;` and the browser decodes them back).
Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id`
`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
### Reuse the real class helpers
Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
### Trivial wrapper divs
A bare `<div class="w-72">…</div>` around a fragment is composed with a `wrap-div` string
helper (or put the class in the parent template), not a Hiccup vector — string composition
of a structural wrapper is not Hiccup and avoids a micro-template per div.
Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
never raw user input.
## Scope (Open decision 2)
Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a
*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
not a single modal. `location-select*` is the first, self-contained proof; the shared
components follow when the sweep promotes them to Selmer partials.
## Attribute-consistency scorecard (heuristic 8)
```bash
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
```
A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain
HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the
shared-component call sites — they clear when those components move to Selmer.)

View File

@@ -1,5 +1,12 @@
# SSR Form & Wizard Simplification — Migration Plan # SSR Form & Wizard Simplification — Migration Plan
> **⚠️ Selmer reverted (2026-06-29).** Pattern 4 below — migrating rendering from Hiccup
> to **Selmer templates** — was **abandoned and reverted**. The swap doctrine, top-rooted
> render functions, and the session-backed wizard engine (patterns 13) were kept; all
> rendering remains in **Hiccup** (`com/*` components). The `auto-ap.ssr.selmer` /
> `auto-ap.ssr.components.selmer` namespaces and the `resources/templates/` tree no longer
> exist. Treat every Selmer instruction below as historical context only.
>
> **Status:** Planning / for execution by an agent or engineer. > **Status:** Planning / for execution by an agent or engineer.
> **Owner:** Bryce > **Owner:** Bryce
> **Type:** Refactor (no user-facing behavior change; parity required). > **Type:** Refactor (no user-facing behavior change; parity required).

View File

@@ -96,7 +96,6 @@
[org.clojure/core.async]] [org.clojure/core.async]]
[hiccup "2.0.0-alpha2"] [hiccup "2.0.0-alpha2"]
[selmer "1.12.61"]
;; needed for java 11 ;; needed for java 11
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"] [javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]

View File

@@ -1,9 +0,0 @@
<a class="{{ extra }} focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center {% if color = "secondary" %}text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:ring-blue-250{% elif color = "primary" %}text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 focus:ring-green-250{% elif color = "red" %}text-white bg-red-500 hover:bg-red-600 focus:ring-red-250 dark:bg-red-600 dark:hover:bg-red-700{% else %}bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border{% endif %}"{{ attrs|safe }}>
{% if indicator %}
<div class="htmx-indicator flex items-center">
{% include "templates/components/spinner.html" %}
<div class="ml-3">Loading...</div>
</div>
{% endif %}
<div class="inline-flex gap-2 items-center justify-center{% if indicator %} htmx-indicator-hidden{% endif %}">{{ body|safe }}</div>
</a>

View File

@@ -1,3 +0,0 @@
<a class="{{ extra }} inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100"{{ attrs|safe }}>
<div class="h-4 w-4">{{ body|safe }}</div>
</a>

View File

@@ -1,2 +0,0 @@
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
</div>

View File

@@ -1,2 +0,0 @@
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
</button>

View File

@@ -1,6 +0,0 @@
<div class="inline-flex rounded-md shadow-sm"
role="group"
hx-on:click="this.querySelector(&quot;input&quot;).value = event.target.value; this.querySelector(&quot;input&quot;).dispatchEvent(new Event('change', {bubbles: true}));">
<input type="hidden" name="{{ name }}">
{{ body|safe }}
</div>

View File

@@ -1,7 +0,0 @@
<button class="{{ extra }} focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50 hover:scale-105 transition duration-100 {% if color = "primary" %}bg-green-500 hover:bg-green-600 focus:ring-green-250 dark:bg-green-600 dark:hover:bg-green-700 text-white{% elif color = "red" %}bg-red-500 hover:bg-red-600 focus:ring-red-250 dark:bg-red-600 dark:hover:bg-red-700 text-white{% else %}bg-white-500 hover:bg-white-600 focus:ring-white-250 dark:bg-white-600 dark:hover:bg-white-700 bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border{% endif %}"{{ attrs|safe }}>
<div class="htmx-indicator flex items-center absolute inset-0 justify-center">
{% include "templates/components/spinner.html" %}
{% if loading_label %}<div class="ml-3">Loading...</div>{% endif %}
</div>
<div class="htmx-indicator-invisible inline-flex gap-2 items-center justify-center">{{ body|safe }}</div>
</button>

View File

@@ -1,3 +0,0 @@
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"
{{ attrs|safe }}>{{ body|safe }}
</td>

View File

@@ -1,10 +0,0 @@
<th class="px-4 py-3{% if klass %} {{ klass }}{% endif %}"
scope="col"
@click="{{ click|safe }}"
{{ attrs|safe }}>
{% if sort_key %}
<a href="#">{{ body|safe }}</a>
{% else %}
{{ body|safe }}
{% endif %}
</th>

View File

@@ -1,2 +0,0 @@
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
</tr>

View File

@@ -1,11 +0,0 @@
<div class="shrink overflow-y-scroll">
<table class="{{ table_class }}"{{ table_attrs|safe }}>
<thead class="{{ thead_class }}">
<tr>{{ headers|safe }}</tr>
</thead>
<tbody>
{{ rows|safe }}
</tbody>
{{ footer_tbody|safe }}
</table>
</div>

View File

@@ -1,3 +0,0 @@
{# Hidden input. The Clojure wrapper (sc/hidden) serializes the full attribute map
(name, value, optional id/form/class/Alpine :value bind) into `attrs`. #}
<input type="hidden"{{ attrs|safe }}>

View File

@@ -1 +0,0 @@
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>

View File

@@ -1,8 +0,0 @@
{# Location <select> for a transaction account row. Plain-HTML attributes -- the Selmer
migration target (no Hiccup keyword/string attribute ambiguity). Rendered into the
surrounding Hiccup row via the auto-ap.ssr.selmer interop bridge. #}
<select name="{{ name }}" class="bg-gray-50 border text-sm rounded-lg block p-2.5 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500 {{ variant }}">
{% for opt in options %}
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>

View File

@@ -1,15 +0,0 @@
{# Base modal-card chrome (single-step: header / optional side panel / body / footer).
A child template extends this and fills the head / side_panel / body / footer blocks,
so the whole card renders from one shared context in a single render call. Enter
triggers the footer save button via $refs.next. Mirrors transaction-edit/edit-modal
(the string-slot version still used by Transaction Edit). #}
<div class="modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150"
@keydown.enter.prevent.stop="if ($refs.next) {$refs.next.click()}"
x-data="">
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0">{% block head %}{% endblock %}</div>
<div class="flex shrink overflow-auto grow">
{% block side_panel %}{% endblock %}
<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{% block body %}{% endblock %}</div>
</div>
<div class="p-4 border-t">{% block footer %}{% endblock %}</div>
</div>

View File

@@ -1,2 +0,0 @@
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}
</div>

View File

@@ -1,4 +0,0 @@
{# Money input (number, step=0.01, right-aligned). Owns its class base; callers pass the
non-class attributes via attrs + a variant (width/size) class. Class set =
inputs/default-input-classes + appearance-none/text-right + the variant. #}
<input type="number" step="0.01" class="bg-gray-50 border text-sm rounded-lg block p-2.5 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500 appearance-none text-right {{ variant }}"{{ attrs|safe }}>

View File

@@ -1,16 +0,0 @@
<ul class="{{ ul_class }}">
{% for opt in options %}
<li class="{{ li_class }}">
<div class="{{ div_class }}">
<input id="{{ opt.id }}"
type="radio"
value="{{ opt.value }}"
name="{{ name }}"
class="{{ input_class }}"
{{ input_attrs|safe }}
{% if opt.checked %}checked{% endif %}>
<label for="{{ opt.id }}" class="{{ label_class }}">{{ opt.content|safe }}</label>
</div>
</li>
{% endfor %}
</ul>

View File

@@ -1,8 +0,0 @@
{# Generic <select>. options = [{value, label, selected}]. Plain-HTML attributes -- the
Selmer migration target (no Hiccup keyword/string attribute ambiguity). Extra attrs
(hx-*, x-*) ride through {{ attrs|safe }}. #}
<select name="{{ name }}" class="bg-gray-50 border text-sm rounded-lg block p-2.5 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500 {{ variant }}"{{ attrs|safe }}>
{% for opt in options %}
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>

View File

@@ -1,11 +0,0 @@
<svg aria-hidden="true"
class="animate-spin inline w-4 h-4 text-white"
fill="none"
role="status"
viewbox="0 0 100 101"
xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB">
</path>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor">
</path>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,7 +0,0 @@
<svg aria-hidden="true"
fill="none"
stroke="currentColor"
viewbox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
</svg>

Before

Width:  |  Height:  |  Size: 240 B

View File

@@ -1,8 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<defs></defs><title>navigation-next</title>
<path d="M23,9.5H12.387a4,4,0,0,0-4,4v2" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor">
</path>
<polyline fill="none" points="19 13.498 23 9.498 19 5.498" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></polyline>
<path d="M14.387,13v5.5a1,1,0,0,1-1,1h-12a1,1,0,0,1-1-1V6.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1V7" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor">
</path>
</svg>

Before

Width:  |  Height:  |  Size: 579 B

View File

@@ -1,3 +0,0 @@
<svg viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<defs></defs><title>delete-2</title><circle cx="12" cy="12" fill="none" r="11.5" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></circle><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="7" x2="17" y1="7" y2="17"></line><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="17" x2="7" y1="7" y2="17"></line>
</svg>

Before

Width:  |  Height:  |  Size: 478 B

View File

@@ -1,3 +0,0 @@
{# Text input. sc/text-input builds the full attr map (type/autocomplete/class+size
already merged, reusing inputs/default-input-classes + hh/add-class) into `attrs`. #}
<input {{ attrs|safe }}>

View File

@@ -1,60 +0,0 @@
{# Click-to-select typeahead (Alpine + tippy). Survives whole-form swaps; null-guarded
tippy?. / $refs.input? throughout. The Clojure wrapper (sc/typeahead) resolves the
initial {value,label} server-side and builds x_data + the hidden-input attrs. #}
<div class="relative"
x-data="{{ x_data }}"
x-modelable="value.value"
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if key %}key="{{ key }}"{% endif %}>
{% if disabled %}
<span x-text="value.label"></span>
{% else %}
<a class="bg-gray-50 border text-sm rounded-lg block p-2.5 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500 cursor-pointer {{ width }}"
x-tooltip.on.click="{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
@keydown.down.prevent.stop="tippy?.show();"
@keydown.backspace="tippy?.hide(); value = {value: '', label: '' }"
tabindex="0"
x-init="{{ a_xinit }}"
x-ref="input"><input {{ hidden_attrs|safe }}>
<div class="flex w-full justify-items-stretch">
<span class="flex-grow text-left" x-text="value.label"></span>
<div class="w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center">
{% include "templates/components/svg-drop-down.html" %}
</div>
<div x-show="value.warning">
<div class="peer absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900 bg-red-300"
x-tooltip="value.warning">!</div>
</div>
</div>
</a>
{% endif %}
<template x-ref="dropdown">
<ul class="dropdown-contents bg-gray-100 dark:bg-gray-600 ring-1"
@keydown.escape="$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
x-destroy="if ($refs.input) {$refs.input.focus();}">
<input type="text"
autofocus
class="bg-gray-50 border-bottom text-sm rounded-t-lg block p-2.5 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500 bg-gray-100 w-full {{ width }}"
x-model="search"
placeholder="{{ placeholder }}"
@change.stop=""
@keydown.down.prevent="active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
@keydown.up.prevent="active --; active = active < 0 ? 0 : active"
@keydown.enter.prevent.stop="$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
x-init="$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})">
<div class="dropdown-options rounded-b-lg overflow-hidden">
<template x-for="(element, index) in elements">
<li><a class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
href="#"
:class="active == index ? 'active' : ''"
@mouseover="active = index"
@mouseout="active = -1"
@click.prevent="value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
x-html="element.label"></a></li>
</template><template x-if="elements.length == 0">
<li class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs ">No results found</li>
</template>
</div>
</ul>
</template>
</div>

View File

@@ -1,12 +0,0 @@
{# Field wrapper with label + always-present error <p> (the errors- variant of field-).
Owns the group / has-error toggle; `has_error` is set when the field has errors,
`extra` is the caller's own class. `attrs` carries any pass-through div attributes (the
per-row location cell hangs its hx-* / x-dispatch swap wiring here); `body` is the
pre-rendered inner control HTML; `errors_str` is the comma-joined string errors. #}
<div class="group {% if has_error %}has-error {% endif %}{{ extra }}"{{ attrs|safe }}>
{% if label %}
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>
{% endif %}
{{ body|safe }}
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p>
</div>

View File

@@ -1,8 +0,0 @@
<div id="interop-smoke" class="p-2">
<h3>{{ title }}</h3>
{# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #}
{{ hiccup_frag|safe }}
<input x-ref="input"
x-model="value.value"
@keydown.down.prevent.stop="tippy?.show()" />
</div>

View File

@@ -1,5 +0,0 @@
{# Top-level plain bulk-edit form (no wizard). The resolved (not-locked) invoice id set
rides in hidden ids[] fields so the selection survives form-changed / submit posts
without an EDN snapshot or a filter round-trip. #}
<form id="bulk-edit-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}
</form>

View File

@@ -1,7 +0,0 @@
{# Running TOTAL / BALANCE percentage rows in their own swappable <tbody>, a sibling of
the input rows, so a percentage edit refreshes them with a targeted swap (Rule 4) and
never replaces the input-bearing rows above. Replaces the old per-cell bulk-edit-total
/ bulk-edit-balance routes. #}
<tbody id="expense-totals">
{{ rows|safe }}
</tbody>

View File

@@ -1,7 +0,0 @@
{# Plain sales-summary edit form (no wizard). db/id rides in a hidden field; all other
state is the live form, re-derived against the entity each request (no EDN snapshot,
no step-params). #}
<form id="summary-edit-form"{{ form_attrs|safe }}>
<input type="hidden" name="db/id" value="{{ db_id }}">
{{ modal|safe }}
</form>

View File

@@ -1,21 +0,0 @@
{# Sales-summary modal body: a read-only Debits | Credits two-column view of the auto
items (each account is inline-editable), a swappable totals/balance block, and an
editable Manual Items section with a working "New Summary Item" add. #}
<div class="space-y-4 p-2">
<div class="grid grid-cols-2 gap-6">
<div>
<div class="font-semibold text-sm mb-2">Debits</div>
<div class="space-y-1">{{ debit_rows|safe }}</div>
</div>
<div>
<div class="font-semibold text-sm mb-2">Credits</div>
<div class="space-y-1">{{ credit_rows|safe }}</div>
</div>
</div>
<div id="summary-totals">{{ totals|safe }}</div>
<div class="mt-4 border-t pt-3">
<div class="font-semibold text-sm mb-2">Manual Items</div>
<div class="space-y-2" id="manual-items">{{ manual_rows|safe }}</div>
<div class="mt-2 flex justify-center">{{ new_item_button|safe }}</div>
</div>
</div>

View File

@@ -1,22 +0,0 @@
{# Expense-account grid -- fully template-driven. A single for-loop over the per-row
view-models (bulk-code/account-row-vm), each delegating to account-row.html. The
trailing "New account" button (a-button partial) posts the whole #bulk-code-form
(op=new-account). #}
<div class="shrink overflow-y-scroll">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink">
<thead class="text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0">
<tr>
<th class="px-4 py-3" scope="col">Account</th>
<th class="px-4 py-3 w-32" scope="col">Location</th>
<th class="px-4 py-3 w-16" scope="col">%</th>
<th class="px-4 py-3 w-16" scope="col"></th>
</tr>
</thead>
<tbody>
{% for row in accounts.rows %}{% include "templates/transaction-bulk-code/account-row.html" %}{% endfor %}
<tr class="new-row border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700">
<td class="px-4 py-2" colspan="4">{% with color=accounts.new_account.color extra=accounts.new_account.extra attrs=accounts.new_account.attrs indicator=accounts.new_account.indicator body=accounts.new_account.body %}{% include "templates/components/a-button.html" %}{% endwith %}</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -1,34 +0,0 @@
{# One expense-account row from a loop-bound `row` view-model. All structure, wiring, and
field names are literal here, built from `row.index` + the shared `urls`; only data (the
Alpine x-data, db/id, errors, and the typeahead / location / money control contexts)
comes from the view-model. The location cell (#account-location-N) swaps just itself on
account change; the remove button swaps the whole #bulk-code-form. #}
<tr class="account-row border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700"
id="account-row-{{ row.index }}" x-data="{{ row.x_data }}" x-ref="p" x-show="show"
x-init="$nextTick(() => show=true)"
x-transition:enter="transition-opacity duration-500" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition duration-500" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<input type="hidden" name="accounts[{{ row.index }}][db/id]"{% if row.db_id_value %} value="{{ row.db_id_value }}"{% endif %}>
<td class="px-4 py-2">
<div class="group {% if row.account_has_error %}has-error {% endif %}">
<div class="flex flex-col">{% with width="" x_data=row.account.x_data x_model=row.account.x_model key=row.account.key disabled=row.account.disabled a_xinit=row.account.a_xinit placeholder=row.account.placeholder hidden_attrs=row.account.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}</div>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ row.account_error }}</p>
</div>
</td>
<td class="px-4 py-2" id="account-location-{{ row.index }}">
<div class="group {% if row.location_has_error %}has-error {% endif %}"
hx-post="{{ urls.changed }}" hx-target="#account-location-{{ row.index }}" hx-select="#account-location-{{ row.index }}"
hx-vals='{"name":"accounts[{{ row.index }}][location]"{% if client_id %},"client-id":{{ client_id }}{% endif %}}'
x-hx-val:account-id="accountId" x-dispatch:changed="accountId" hx-trigger="changed" hx-swap="outerHTML" hx-include="closest form">
{% with name=row.location.name variant="w-full" options=row.location.options %}{% include "templates/components/location-select.html" %}{% endwith %}
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ row.location_error }}</p>
</div>
</td>
<td class="px-4 py-2">
<div class="group {% if row.pct_has_error %}has-error {% endif %}">
{% with variant=row.pct.variant attrs=row.pct.attrs %}{% include "templates/components/money-input.html" %}{% endwith %}
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ row.pct_error }}</p>
</div>
</td>
<td class="px-4 py-2 align-top"><a href="" class="account-remove-action p-3 inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100" hx-post="{{ urls.changed }}" hx-vals='{"op":"remove-account","row-index":{{ row.index }}}' hx-target="#bulk-code-form" hx-select="#bulk-code-form" hx-swap="outerHTML" hx-include="closest form"><div class="h-4 w-4">{% include "templates/components/svg-x.html" %}</div></a></td>
</tr>

View File

@@ -1,35 +0,0 @@
{# Bulk-code modal body: vendor typeahead (a change repopulates the default account via a
whole-form swap), status select, and the expense-account grid. All wiring, the status
options, and the field-wrapper classes are literal here; only data (selected values,
resolved labels, errors) comes from the view-model. #}
<div class="space-y-4 p-4">
<div class="grid grid-cols-2 gap-4">
<div hx-trigger="change" hx-post="{{ urls.changed }}" hx-vals='{"op":"vendor-changed"}' hx-target="#bulk-code-form" hx-select="#bulk-code-form" hx-swap="outerHTML" hx-sync="this:replace" hx-include="closest form">
<div class="group {% if vendor.has_error %}has-error {% endif %}">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Vendor</label>
{% with width="w-96" x_data=vendor.ta.x_data x_model=vendor.ta.x_model key=vendor.ta.key disabled=vendor.ta.disabled a_xinit=vendor.ta.a_xinit placeholder=vendor.ta.placeholder hidden_attrs=vendor.ta.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ vendor.error }}</p>
</div>
</div>
<div>
<div class="group {% if status.has_error %}has-error {% endif %}">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select name="approval-status" class="bg-gray-50 border text-sm rounded-lg block p-2.5 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 group-[.has-error]:bg-red-50 group-[.has-error]:border-red-500 group-[.has-error]:text-red-900 group-[.has-error]:placeholder-red-700 group-[.has-error]:focus:ring-red-500 group-[.has-error]:dark:bg-gray-700 group-[.has-error]:focus:border-red-500 group-[.has-error]:dark:text-red-500 group-[.has-error]:dark:placeholder-red-500 group-[.has-error]:dark:border-red-500">
<option value=""{% if not status.value %} selected{% endif %}>No Change</option>
<option value="approved"{% if status.value = "approved" %} selected{% endif %}>Approved</option>
<option value="unapproved"{% if status.value = "unapproved" %} selected{% endif %}>Unapproved</option>
<option value="suppressed"{% if status.value = "suppressed" %} selected{% endif %}>Suppressed</option>
<option value="requires-feedback"{% if status.value = "requires-feedback" %} selected{% endif %}>Client Review</option>
</select>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ status.error }}</p>
</div>
</div>
<div class="col-span-2 pt-4">
<h3 class="text-lg font-medium mb-3">Expense Accounts</h3>
<div class="group">
<div id="account-entries" class="space-y-3">{% include "templates/transaction-bulk-code/account-grid.html" %}</div>
<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ accounts.error }}</p>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +0,0 @@
{# Bulk-code modal card: extends the shared modal-card base and fills its blocks with the
bulk-code head / body / footer partials. No side panel. #}
{% extends "templates/components/modal-card.html" %}
{% block head %}{% include "templates/transaction-bulk-code/head.html" %}{% endblock %}
{% block body %}{% include "templates/transaction-bulk-code/body.html" %}{% endblock %}
{% block footer %}{% include "templates/transaction-bulk-code/footer.html" %}{% endblock %}

View File

@@ -1,5 +0,0 @@
{# Modal footer: the form-errors sink on the left, the Save button on the right. Both
pull from the shared view-model; the button reuses components/button.html. #}
<div class="flex justify-end">
<div class="flex items-baseline gap-x-4">{% include "templates/transaction-bulk-code/form-errors.html" %}{% with color=save.color extra=save.extra attrs=save.attrs loading_label=save.loading_label body=save.body %}{% include "templates/components/button.html" %}{% endwith %}</div>
</div>

View File

@@ -1,4 +0,0 @@
{# Submit-error sink. A 4xx submit swaps the inner `.error-content` (hx-target-400);
the span is present only when there are form-level errors, matching the prior
hand-rolled markup byte-for-byte. #}
<div id="form-errors">{% if errors_str %}<span class="error-content"><p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p></span>{% endif %}</div>

View File

@@ -1,5 +0,0 @@
{# Single render entrypoint for the bulk-code form. The route passes one view-model and
every sub-template composes from it via includes/blocks. The resolved (not-locked)
transaction id set rides in hidden ids[] fields so the selection survives
form-changed / submit posts without an EDN snapshot or a filter round-trip. #}
<form id="bulk-code-form" hx-ext="response-targets" hx-swap="outerHTML" hx-target-400="#form-errors .error-content" hx-trigger="submit" hx-target="this" hx-post="{{ urls.submit }}">{% for id in ids %}<input type="hidden" name="ids[{{ forloop.counter0 }}]" value="{{ id }}">{% endfor %}<div class="" @click.outside="open=false" id="bulkcodemodal">{% include "templates/transaction-bulk-code/card.html" %}</div></form>

View File

@@ -1,2 +0,0 @@
{# Modal header label: how many transactions this bulk-code operation will touch. #}
<div class="p-2">Bulk editing {{ head_count }} transactions</div>

View File

@@ -1,3 +0,0 @@
{# Modal-open response: the transitioner shell the modal stack expects, wrapping the whole
form. Composes in one render from the shared view-model. #}
<div id="transitioner" class="flex-1">{% include "templates/transaction-bulk-code/form.html" %}</div>

View File

@@ -1,2 +0,0 @@
{# Post-submit confirmation message embedded in the shared success modal. #}
<p>Successfully coded {{ count }} transactions.</p>

View File

@@ -1,5 +0,0 @@
{# Totals live in their own swappable <tbody> so an amount edit refreshes them with a
targeted swap, never replacing the input-bearing rows above (caret survives). #}
<tbody id="account-totals">
{{ rows|safe }}
</tbody>

View File

@@ -1,4 +0,0 @@
<div x-data="{{ x_data }}">
{{ status_hidden|safe }}
<div class="inline-flex rounded-md shadow-sm" role="group">{{ buttons|safe }}</div>
</div>

View File

@@ -1,39 +0,0 @@
{# Read-only transaction summary shown in the modal's left side panel. #}
<div class="p-4 space-y-4">
<h3 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Details</h3>
<div class="space-y-3">
<div>
<div class="text-xs font-medium text-gray-500">Amount</div>
<div class="text-sm font-medium text-gray-900">{{ amount }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Date</div>
<div class="text-sm text-gray-900">{{ date }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Bank Account</div>
<div class="text-sm text-gray-900">{{ bank_account }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Post Date</div>
<div class="text-sm text-gray-900">{{ post_date }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Description</div>
<div class="text-sm text-gray-900 truncate cursor-help"
title="{{ description_original }}">{{ description_simple }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Check Number</div>
<div class="text-sm text-gray-900">{{ check_number }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Status</div>
<div class="text-sm text-gray-900">{{ status }}</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500">Transaction Type</div>
<div class="text-sm text-gray-900">{{ type }}</div>
</div>
</div>
</div>

View File

@@ -1,7 +0,0 @@
{# Top-level plain form. The entity id rides in a hidden field; all other state is the
live form, re-derived against the entity each request (no serialized snapshot, no
wizard step-params). #}
<form id="edit-form"{{ form_attrs|safe }}>
<input type="hidden" name="db/id" value="{{ db_id }}">
{{ modal|safe }}
</form>

View File

@@ -1,15 +0,0 @@
{# Modal card chrome (header / optional side panel / body / footer). Single-step, so
no timeline, no back/next nav -- just the Done button in the footer. Enter triggers
the save button via $refs.next. #}
<div class="modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150"
@keydown.enter.prevent.stop="if ($refs.next) {$refs.next.click()}"
x-data="">
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0">{{ head|safe }}</div>
<div class="flex shrink overflow-auto grow">
{% if side_panel %}
<div class="grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full">{{ side_panel|safe }}</div>
{% endif %}
<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{{ body|safe }}</div>
</div>
<div class="p-4 border-t">{{ footer|safe }}</div>
</div>

View File

@@ -1,6 +0,0 @@
<div class="ml-3">
<span class="block text-sm font-medium">{{ number }}</span>
<span class="block text-sm text-gray-500">{{ vendor }}</span>
<span class="block text-sm text-gray-500">{{ date }}</span>
<span class="block text-sm font-medium">{{ amount }}</span>
</div>

View File

@@ -1,27 +0,0 @@
<div class="my-4 p-4 bg-blue-50 rounded">
<h3 class="text-lg font-bold mb-2">Linked Payment{{ external_link|safe }}</h3>
<div class="space-y-2">
<div class="flex justify-between">
<div class="font-medium">Payment #</div>
<div>{{ number }}</div>
</div>
<div class="flex justify-between">
<div class="font-medium">Vendor</div>
<div>{{ vendor }}</div>
</div>
<div class="flex justify-between">
<div class="font-medium">Amount</div>
<div>{{ amount }}</div>
</div>
<div class="flex justify-between">
<div class="font-medium">Status</div>
<div>{{ status }}</div>
</div>
<div class="flex justify-between">
<div class="font-medium">Date</div>
<div>{{ date }}</div>
</div>
{{ payment_id_hidden|safe }}<div class="mt-4"{{ unlink_attrs|safe }}>{{ unlink_button|safe }}
</div>
</div>
</div>

View File

@@ -1,32 +0,0 @@
{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid /
autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #}
<div class="space-y-1">
<div>
{{ memo_field|safe }}
<div x-data="{{ x_data }}" @unlinked="canChange=true">
<div class="flex space-x-2 mb-4">{{ action_hidden|safe }}{{ tabs|safe }}</div>
<div x-show="activeForm === 'link-payment'"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_payment|safe }}</div>
<div x-show="activeForm === 'link-unpaid-invoices'"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_unpaid|safe }}</div>
<div x-show="activeForm === 'link-autopay-invoices'"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_autopay|safe }}</div>
<div x-show="activeForm === 'apply-rule'"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100">{{ panel_rule|safe }}</div>
<div x-show="activeForm === 'manual'"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100">
<div>{{ panel_manual|safe }}</div>
</div>
</div>
</div>
</div>

View File

@@ -1,11 +0,0 @@
{# Vendor field (a change repopulates the default account via a whole-form swap) + either
the simple single-row coding or the advanced account grid. #}
<div id="manual-coding-section">
{{ mode_hidden|safe }}<div {{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}
</div>
{% if is_simple %}
<div x-data="{{ simple_xdata }}">{{ simple_mode|safe }}</div>
{% else %}
<div>{{ toggle_link|safe }}{{ accounts_field|safe }}</div>
{% endif %}
</div>

View File

@@ -1 +0,0 @@
<div class="text-center py-4 text-gray-500">{{ message }}</div>

View File

@@ -1,10 +0,0 @@
{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden +
prompt label + a radio-card of options. #}
<div>
<h3 class="text-lg font-bold mb-4">{{ heading }}</h3>
{{ action_hidden|safe }}
<div class="space-y-2">
<label class="block text-sm font-medium mb-1">{{ prompt }}</label>
{{ radio|safe }}
</div>
</div>

View File

@@ -1,2 +0,0 @@
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
<div id="payment-matches">{{ inner|safe }}</div>

View File

@@ -1,4 +0,0 @@
<div class="ml-3">
<span class="block text-sm font-medium">{{ note }}</span>
<span class="block text-sm text-gray-500">{{ description }}</span>
</div>

View File

@@ -1,14 +0,0 @@
{# Simple mode: a single account row (account typeahead + location select) rendered at a
fixed index 0, plus the link to switch to the advanced grid. Selecting the account
swaps just the location cell (#simple-account-location). #}
<div>
<span>{{ row_id_hidden|safe }}
<div class="flex gap-2 mt-2">
{{ account_field|safe }}
<div id="simple-account-location">{{ location_field|safe }}</div>
{{ amount_hidden|safe }}
</div>
</span>
<div class="mt-1"><a class="text-sm text-blue-600 hover:underline cursor-pointer"
{{ toggle_attrs|safe }}>Switch to advanced mode</a></div>
</div>

View File

@@ -1,3 +0,0 @@
{# Wrapper the modal stack expects around the opened form (the wizard transition hooks
are gone -- there is only one step). #}
<div id="transitioner" class="flex-1">{{ body|safe }}</div>

View File

@@ -30,6 +30,7 @@
(def success-modal dialog/success-modal-) (def success-modal dialog/success-modal-)
(def modal-card dialog/modal-card-) (def modal-card dialog/modal-card-)
(def modal-card-advanced dialog/modal-card-advanced-) (def modal-card-advanced dialog/modal-card-advanced-)
(def single-modal-card dialog/single-modal-card-)
(def modal-header dialog/modal-header-) (def modal-header dialog/modal-header-)
(def modal-header-attachment dialog/modal-header-attachment-) (def modal-header-attachment dialog/modal-header-attachment-)
(def modal-body dialog/modal-body-) (def modal-body dialog/modal-body-)

View File

@@ -34,7 +34,7 @@
[:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex (hx/alpine-appear {:x-show "unexpectedError" :class "dark:bg-red-900 dark:text-red-300"}) [:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex (hx/alpine-appear {:x-show "unexpectedError" :class "dark:bg-red-900 dark:text-red-300"})
[:span {:class "w-2 h-2 mr-1 bg-red-500 rounded-full"}] [:span.px-2.py-0.5 "An unexpected error has occured. Integreat staff have been notified."]] [:span {:class "w-2 h-2 mr-1 bg-red-500 rounded-full"}] [:span.px-2.py-0.5 "An unexpected error has occured. Integreat staff have been notified."]]
(when (:error params) (when (:error params)
[:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex {:class "dark:bg-red-900 dark:text-red-300"} [:span.items-center.bg-red-100.text-red-800.text-xs.font-medium.mb-2.p-1.rounded-full.inline-flex {:class "dark:bg-red-900 dark:text-red-300"}
[:span {:class "w-2 h-2 mr-1 bg-red-500 rounded-full"}] [:span {:class "w-2 h-2 mr-1 bg-red-500 rounded-full"}]
[:span.px-2.py-0.5 (:error params)]]) [:span.px-2.py-0.5 (:error params)]])
[:div {:class "shrink-0"} [:div {:class "shrink-0"}
@@ -63,9 +63,25 @@
(defn modal-card-advanced- [params & children] (defn modal-card-advanced- [params & children]
[:div (merge params [:div (merge params
{:class (hh/add-class "modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen" (:class params ""))}) {:class (hh/add-class "modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen" (:class params ""))})
children]) children])
(defn single-modal-card-
"Single-step modal-card chrome (header / optional side panel / body / footer) at the
standard md:w-[950px] md:h-[650px] size. Enter triggers the footer save button via
$refs.next. Reproduces the former Selmer templates/components/modal-card.html and
transaction-edit/edit-modal.html, so the modal-size classes live in one Clojure source."
[{:keys [side-panel]} head body footer]
[:div {:class "modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150"
"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:x-data ""}
[:div {:class "flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0"} head]
[:div {:class "flex shrink overflow-auto grow"}
(when side-panel
[:div {:class "grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full"} side-panel])
[:div {:class "px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow"} body]]
[:div {:class "p-4 border-t"} footer]])
(defn success-modal- [{:keys [title]} & children] (defn success-modal- [{:keys [title]} & children]
(modal- {} (modal- {}
(modal-card-advanced- (modal-card-advanced-

View File

@@ -1,319 +0,0 @@
(ns auto-ap.ssr.components.selmer
"Selmer-rendered versions of the shared SSR components used by the Transaction Edit
modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data
context and renders its own template under resources/templates/components/ via the
interop bridge -- the element structure lives entirely in the .html templates; the
only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call
site) are serialized to an attribute string by `attrs->str` and injected with
{{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders.
Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup
components byte-for-byte modulo Tailwind class ordering (verify by string-match +
e2e, never byte-parity -- see selmer-conventions.md)."
(:require
[auto-ap.ssr.components.inputs :as inputs]
[auto-ap.ssr.hiccup-helper :as hh]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.selmer :as sel]
[clojure.string :as str]
[hiccup.util :as hu]))
(defn- attr-name [k]
(if (keyword? k) (subs (str k) 1) (str k)))
(defn attrs->str
"Serialize an attribute map to an HTML attribute string with a leading space, so it
concatenates after fixed template attributes: <input type=\"text\"{{ attrs|safe }}>.
nil/false values are dropped, true renders a bare boolean attribute, everything else
renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes."
[m]
(->> m
(keep (fn [[k v]]
(cond
(nil? v) nil
(false? v) nil
(true? v) (str " " (attr-name k))
:else (str " " (attr-name k) "=\""
(hu/escape-html (if (keyword? v) (name v) (str v)))
"\""))))
(apply str)))
(defn render
"Render a component partial and trim outer whitespace (so {# comments #} and the
file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped
string ready to drop into Hiccup or another Selmer context value."
[template ctx]
(sel/raw (str/trim (sel/render template ctx))))
(defn- body->html
"Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string."
[body]
(->> (if (sequential? body) body [body])
(remove nil?)
(map sel/hiccup->html)
(apply str)))
;; --- leaf inputs -----------------------------------------------------------------
(defn hidden [{:keys [name value] :as params}]
(render "templates/components/hidden.html"
{:attrs (attrs->str (merge {:name name}
(when (some? value) {:value value})
(dissoc params :name :value)))}))
(defn text-input [{:keys [size] :as params}]
(let [attrs (-> params
(dissoc :error? :size)
(assoc :type "text" :autocomplete "off")
(update :class #(-> ""
(hh/add-class inputs/default-input-classes)
(hh/add-class %)))
(update :class #(str % (inputs/use-size size))))]
(render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
(defn money-input-ctx
"Plain-data context for templates/components/money-input.html. The class base is owned
by the template; this passes the non-class attributes (name/value/...) and the variant
class (caller width + size). Split out so a template can include the partial directly."
[{:keys [size class] :as params}]
{:variant (str (or class "") (inputs/use-size size))
:attrs (attrs->str (dissoc params :class :size))})
(defn money-input [params]
(render "templates/components/money-input.html" (money-input-ctx params)))
(defn select-ctx
"Plain-data context for templates/components/select.html. options = [[value label] ...];
`value` (string or keyword) marks the selected option. Split out so a template can
{% include %} the partial via {% with %} without re-deriving classes/selection."
[{:keys [name value options class] :as params}]
(let [sel (cond-> value (keyword? value) clojure.core/name)
attrs (dissoc params :name :value :options :class)]
{:name name
:variant (or class "")
:attrs (attrs->str attrs)
:options (for [[v label] options]
{:value v :label label :selected (= (str v) (str sel))})}))
(defn select
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
generalized). See select-ctx."
[params]
(render "templates/components/select.html" (select-ctx params)))
;; --- field wrapper ---------------------------------------------------------------
(defn errors-str
"Comma-join the string errors at a field (nil/empty -> empty string), matching the
validated-field error <p>."
[errors]
(or (when (sequential? errors)
(str/join ", " (filter string? errors)))
""))
(defn validated-field
"Selmer port of com/validated-field (the errors- variant of field-): label + body +
an always-present error <p>. Pass-through attrs land on the wrapping div (the account
row's location cell hangs its swap wiring here)."
[{:keys [label errors class] :as params} & body]
(let [attrs (dissoc params :label :errors :error-source :error-key :class)]
(render "templates/components/validated-field.html"
{:label label
:has_error (sequential? errors)
:extra (or class "")
:attrs (attrs->str attrs)
:body (body->html body)
:errors_str (errors-str errors)})))
;; --- buttons / badges / links ----------------------------------------------------
(defn badge [{:keys [color] :as params} & children]
(let [classes (-> (hh/add-class
"absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white \n border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900"
(:class params))
(hh/add-class (or (some-> color (#(str "bg-" % "-300"))) "bg-red-300")))]
(render "templates/components/badge.html"
{:classes classes
:attrs (attrs->str (dissoc params :class))
:body (body->html children)})))
(defn link [{:keys [class] :as params} & children]
(render "templates/components/link.html"
{:classes (str class " font-medium text-blue-600 dark:text-blue-500 hover:underline cursor-pointer")
:attrs (attrs->str (dissoc params :class))
:body (body->html children)}))
(defn button-ctx
"Plain-data context for templates/components/button.html. The class base + color ladder
are owned by the template; this passes the color (name), the caller's extra class, the
non-class attrs, loading_label and body. NB: Selmer button callers only pass static
colors (primary); dynamic colors go through the Hiccup com/button."
[{:keys [color minimal-loading?] :as params} & children]
{:color (some-> color name)
:extra (or (:class params) "")
:attrs (attrs->str (dissoc params :class))
:loading_label (not minimal-loading?)
:body (body->html children)})
(defn button [params & children]
(render "templates/components/button.html" (apply button-ctx params children)))
(defn a-button-ctx
"Plain-data context for templates/components/a-button.html. The class base + color
ladder (secondary/primary/red/default) are owned by the template; this passes the
color (name), the caller's extra class, the non-class attrs, indicator and body."
[{:keys [color] :as params} & children]
{:color (some-> color name)
:extra (or (:class params) "")
:attrs (attrs->str (-> (dissoc params :class)
(assoc :tabindex 0 :href (:href params "#"))))
:indicator (:indicator? params true)
:body (body->html children)})
(defn a-button [params & children]
(render "templates/components/a-button.html" (apply a-button-ctx params children)))
(defn a-icon-button-ctx
"Plain-data context for templates/components/a-icon-button.html. The fixed class base is
owned by the template; `extra` is the caller class plus the conditional p-3 padding."
[{:keys [class] :as params} & children]
(let [class-str (or class "")
has-padding? (re-find #"\bp[xy]?-\d+(\.\d+)?\b" class-str)]
{:extra (str class-str (if has-padding? "" " p-3"))
:attrs (attrs->str (-> (dissoc params :class)
(assoc :href (or (:href params) ""))))
:body (body->html children)}))
(defn a-icon-button [params & children]
(render "templates/components/a-icon-button.html" (apply a-icon-button-ctx params children)))
(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children]
(let [classes (cond-> (:class params)
true (str " font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-green-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500 dark:focus:text-white disabled:opacity-50")
(= :small size) (str " text-xs px-3 py-2")
(= :normal size) (str " text-sm px-4 py-2"))]
(render "templates/components/button-group-button.html"
{:classes classes
:attrs (attrs->str (-> (dissoc params :class :size)
(assoc :type (or (:type params) "button"))))
:body (body->html children)})))
(defn button-group [{:keys [name]} & children]
(render "templates/components/button-group.html"
{:name name
:body (body->html children)}))
;; --- radio-card ------------------------------------------------------------------
(defn radio-card
"Selmer port of com/radio-card. NB: the Hiccup radio-card- has a dangling [:h3 title]
the let discards, so only the <ul> renders -- reproduced here. Only the documented
htmx keys ride onto each <input> (the same select-keys filter; :hx-vals / :hx-select
are intentionally dropped, matching existing behavior)."
[{:keys [options name title size orientation width] :or {size :medium width "w-48"}
selected-value :value :as params}]
(let [htmx-attrs (select-keys params [:hx-post :hx-target :hx-swap :hx-include :hx-trigger])
sel (cond-> selected-value (keyword? selected-value) clojure.core/name)
ul-class (cond-> " text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
(= orientation :horizontal) (-> (hh/add-class "flex gap-2 flex-wrap")
(hh/remove-wildcard ["w-" "rounded-lg" "border" "bg-"]))
:always (str " " width " "))
li-class (cond-> "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600"
(= orientation :horizontal) (-> (hh/remove-wildcard ["w-full" "rounded-"])
(hh/add-class "w-auto shrink-0 block rounded-lg border border-gray-200 dark:border-gray-600 px-3")))
div-class (cond-> "flex items-center"
(not= orientation :horizontal) (hh/add-class "pl-3"))
input-class (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
(= size :small) (str " text-xs")
(= size :medium) (str " text-sm"))
label-class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300"
(= size :small) (str " text-xs py-2")
(= size :medium) (str " text-sm py-3")
(= orientation :horizontal) (hh/remove-class "w-full"))]
(render "templates/components/radio-card.html"
{:ul_class ul-class :li_class li-class :div_class div-class
:input_class input-class :label_class label-class
:name name
:input_attrs (attrs->str htmx-attrs)
:options (for [{:keys [value content]} options]
{:id (str "list-" name "-" value)
:value value
:checked (= sel value)
:content (body->html content)})})))
;; --- data grid -------------------------------------------------------------------
(defn data-grid-header [params & body]
(render "templates/components/data-grid-header.html"
{:klass (:class params)
:click (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
:sort_key (:sort-key params)
:attrs (attrs->str (cond-> {} (:style params) (assoc :style (:style params))))
:body (body->html body)}))
(defn data-grid-row [params & body]
(render "templates/components/data-grid-row.html"
{:classes (str (:class params) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
:attrs (attrs->str (dissoc params :class))
:body (body->html body)}))
(defn data-grid-cell [params & body]
(render "templates/components/data-grid-cell.html"
{:klass (:class params)
:attrs (attrs->str (dissoc params :class))
:body (body->html body)}))
(defn data-grid
"Table shell: outer scroll div > table > thead(headers) > tbody(rows) + optional
footer-tbody. `headers`, `rows`, and `footer-tbody` are pre-rendered fragments."
[{:keys [headers footer-tbody] :as params} & rows]
(render "templates/components/data-grid.html"
{:table_class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"
:table_attrs (attrs->str (dissoc params :headers :thead-params :footer-tbody))
:thead_class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
:headers (body->html headers)
:rows (body->html rows)
:footer_tbody (when footer-tbody (body->html footer-tbody))}))
;; --- modal + typeahead -----------------------------------------------------------
(defn modal [{:as params} & children]
(render "templates/components/modal.html"
{:classes (hh/add-class "" (:class params ""))
:attrs (attrs->str (dissoc params :handle-unexpected-error? :class))
:body (body->html children)}))
(defn typeahead-ctx
"Build the plain-data context map for templates/components/typeahead.html. Resolves the
initial {value,label} server-side via value-fn/content-fn (DB lookups), builds the
Alpine x-data, and serializes the hidden posting-input attributes. Split out from
`typeahead` so a fully template-driven grid can feed the same partial per row (via
{% with %}) without re-deriving any of this logic. Every value is a string/boolean."
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
:as params}]
(let [vf (or value-fn identity)
cf (or content-fn identity)
vval (vf value)
vlabel (cf value)
x-data (hx/json {:baseUrl (str url)
:value {:value vval :label vlabel}
:tippy nil :search "" :active -1
:elements (if vval [{:value vval :label vlabel}] [])})
a-xinit (str "$nextTick(() => tippy = $el.__x_tippy); " x-init)
hidden-attrs (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc "x-ref" "hidden" :type "hidden" ":value" "value.value"
:x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))]
{:x_data x-data
:x_model x-model
:key (when id (str id "--" vval))
:disabled disabled
:width (or class "")
:a_xinit a-xinit
:placeholder placeholder
:hidden_attrs (attrs->str hidden-attrs)}))
(defn typeahead
"Selmer port of com/typeahead. Preserves every tippy?. null-guard. See typeahead-ctx."
[params]
(render "templates/components/typeahead.html" (typeahead-ctx params)))

View File

@@ -30,12 +30,10 @@
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.components.wizard-state :as ws] [auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.components.wizard2 :as wizard2] [auto-ap.ssr.components.wizard2 :as wizard2]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.nested-form-params :as nfp :refer [wrap-nested-form-params]] [auto-ap.ssr.nested-form-params :as nfp :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.transaction.edit :as tx-edit] [auto-ap.ssr.transaction.edit :as tx-edit]
[auto-ap.ssr.hiccup-helper :as hh] [auto-ap.ssr.hiccup-helper :as hh]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
@@ -1473,16 +1471,16 @@
(defn- account-typeahead* (defn- account-typeahead*
[{:keys [name value client-id x-model]}] [{:keys [name value client-id x-model]}]
(sc/typeahead {:name name (com/typeahead {:name name
:placeholder "Search..." :placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search) :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:purpose "invoice"}) {:purpose "invoice"})
:id name :id name
:x-model x-model :x-model x-model
:value value :value value
:content-fn (fn [value] :content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})) client-id)))}))
;; TODO clientize ;; TODO clientize
(defn all-ids-not-locked [all-ids] (defn all-ids-not-locked [all-ids]
@@ -1514,7 +1512,7 @@
:hx-select (str "#account-location-" index) :hx-select (str "#account-location-" index)
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form"}] :hx-include "closest form"}]
(sc/data-grid-row (com/data-grid-row
(-> {:class "account-row" (-> {:class "account-row"
:id (str "account-row-" index) :id (str "account-row-" index)
:x-data (hx/json {:show (boolean (not (:new? value))) :x-data (hx/json {:show (boolean (not (:new? value)))
@@ -1522,47 +1520,47 @@
:data-key "show" :data-key "show"
:x-ref "p"} :x-ref "p"}
hx/alpine-mount-then-appear) hx/alpine-mount-then-appear)
(sc/hidden {:name (account-field-name index :db/id) (com/hidden {:name (account-field-name index :db/id)
:value (:db/id value)}) :value (:db/id value)})
(sc/data-grid-cell (com/data-grid-cell
{} {}
(sc/validated-field (com/validated-field
{:errors (account-field-errors index :account)} {:errors (account-field-errors index :account)}
(account-typeahead* {:value account-val (account-typeahead* {:value account-val
:client-id client-id :client-id client-id
:name (account-field-name index :account) :name (account-field-name index :account)
:x-model "accountId"}))) :x-model "accountId"})))
(sc/data-grid-cell (com/data-grid-cell
{:id (str "account-location-" index)} {:id (str "account-location-" index)}
(sc/validated-field (com/validated-field
(merge {:errors (account-field-errors index :location)} location-attrs) (merge {:errors (account-field-errors index :location)} location-attrs)
(tx-edit/location-select* {:name (account-field-name index :location) (tx-edit/location-select* {:name (account-field-name index :location)
:account-location (:account/location (when (nat-int? account-val) :account-location (:account/location (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val))) (dc/pull (dc/db conn) '[:account/location] account-val)))
:value (:location value)}))) :value (:location value)})))
(sc/data-grid-cell (com/data-grid-cell
{} {}
(sc/validated-field (com/validated-field
{:errors (account-field-errors index :percentage)} {:errors (account-field-errors index :percentage)}
(sc/money-input {:name (account-field-name index :percentage) (com/money-input {:name (account-field-name index :percentage)
:class "w-16 amount-field" :class "w-16 amount-field"
:value (some-> (:percentage value) (* 100) long) :value (some-> (:percentage value) (* 100) long)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-target "#expense-totals" :hx-target "#expense-totals"
:hx-select "#expense-totals" :hx-select "#expense-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"})))
(sc/data-grid-cell
{:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-edit-form"
:hx-select "#bulk-edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form" :hx-trigger "keyup changed delay:300ms"
:class "account-remove-action"} :hx-include "closest form"})))
svg/x))))) (com/data-grid-cell
{:class "align-top"}
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-edit-form"
:hx-select "#bulk-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
svg/x)))))
(defn- expense-total* [request] (defn- expense-total* [request]
(let [total (->> (-> request :bulk-state :expense-accounts) (let [total (->> (-> request :bulk-state :expense-accounts)
@@ -1577,36 +1575,33 @@
(filter number?) (filter number?)
(reduce + 0.0)) (reduce + 0.0))
balance (- 100.0 (* 100.0 total))] balance (- 100.0 (* 100.0 total))]
(sel/raw (str "<span" [:span (when-not (dollars= 0.0 balance) {:class "text-red-300"})
(when-not (dollars= 0.0 balance) " class=\"text-red-300\"") (format "%.1f%%" balance)]))
">" (format "%.1f%%" balance) "</span>"))))
(defn- expense-totals-tbody* (defn- expense-totals-tbody*
"The separately-swappable TOTAL/BALANCE <tbody> (#expense-totals, Rule 4 target)." "The separately-swappable TOTAL/BALANCE <tbody> (#expense-totals, Rule 4 target)."
[request] [request]
(sel/render->hiccup [:tbody {:id "expense-totals"}
"templates/invoice-bulk-edit/expense-totals.html" (com/data-grid-row {}
{:rows (str (com/data-grid-cell {})
(sc/data-grid-row {} (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(sc/data-grid-cell {}) (com/data-grid-cell {:id "expense-total" :class "text-right"} (expense-total* request))
(sc/data-grid-cell {:class "text-right"} (sel/raw "<span class=\"font-bold text-right\">TOTAL</span>")) (com/data-grid-cell {}))
(sc/data-grid-cell {:id "expense-total" :class "text-right"} (expense-total* request)) (com/data-grid-row {}
(sc/data-grid-cell {})) (com/data-grid-cell {})
(sc/data-grid-row {} (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(sc/data-grid-cell {}) (com/data-grid-cell {:id "expense-balance" :class "text-right"} (expense-balance* request))
(sc/data-grid-cell {:class "text-right"} (sel/raw "<span class=\"font-bold text-right\">BALANCE</span>")) (com/data-grid-cell {}))])
(sc/data-grid-cell {:id "expense-balance" :class "text-right"} (expense-balance* request))
(sc/data-grid-cell {})))}))
(defn- account-grid* [request] (defn- account-grid* [request]
(let [client-id (single-client-id request) (let [client-id (single-client-id request)
accounts (vec (:expense-accounts (:bulk-state request)))] accounts (vec (:expense-accounts (:bulk-state request)))]
(apply (apply
sc/data-grid com/data-grid
{:headers [(sc/data-grid-header {} "Account") {:headers [(com/data-grid-header {} "Account")
(sc/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-32"} "Location")
(sc/data-grid-header {:class "w-16"} "%") (com/data-grid-header {:class "w-16"} "%")
(sc/data-grid-header {:class "w-16"})] (com/data-grid-header {:class "w-16"})]
:footer-tbody (expense-totals-tbody* request)} :footer-tbody (expense-totals-tbody* request)}
(concat (concat
(map-indexed (map-indexed
@@ -1615,17 +1610,17 @@
:client-id client-id :client-id client-id
:index index})) :index index}))
accounts) accounts)
[(sc/data-grid-row [(com/data-grid-row
{:class "new-row"} {:class "new-row"}
(sc/data-grid-cell {:colspan 4} (com/data-grid-cell {:colspan 4}
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed) (com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-vals (hx/json {:op "new-account"}) :hx-vals (hx/json {:op "new-account"})
:hx-target "#bulk-edit-form" :hx-target "#bulk-edit-form"
:hx-select "#bulk-edit-form" :hx-select "#bulk-edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form" :hx-include "closest form"
:color :secondary} :color :secondary}
"New account")))])))) "New account")))]))))
(defn maybe-code-accounts [invoice account-rules valid-locations] (defn maybe-code-accounts [invoice account-rules valid-locations]
(with-precision 2 (with-precision 2
@@ -1668,51 +1663,36 @@
(when-not (dollars= 1.0 expense-account-total) (when-not (dollars= 1.0 expense-account-total)
(form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%"))))) (form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%")))))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request] (defn- footer* [request]
(sel/raw [:div.flex.justify-end
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">" [:div.flex.items-baseline.gap-x-4
(form-errors-html (:errors (:form-errors request))) (com/form-errors {:errors (seq (:errors (:form-errors request)))})
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save")) (com/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save")]])
"</div></div>")))
(defn render-form (defn render-form
"Renders the whole plain bulk-edit form (no wizard). Binds *errors* so the field-level "Renders the whole plain bulk-edit form (no wizard). Binds *errors* so the field-level
lookups resolve. Reuses the edit modal chrome." lookups resolve. Reuses the edit modal chrome."
[request] [request]
(binding [*errors* (or (:form-errors request) {})] (binding [*errors* (or (:form-errors request) {})]
(let [ids (:ids (:bulk-state request)) (let [ids (:ids (:bulk-state request))]
ids-hidden (apply str [:form {:id "bulk-edit-form"
(map-indexed (fn [i id] :hx-ext "response-targets"
(str (sc/hidden {:name (path->name2 :ids i) :value id}))) :hx-swap "outerHTML"
ids)) :hx-target-400 "#form-errors .error-content"
body (str "<div class=\"space-y-4 p-4\">" :hx-trigger "submit"
(str (sc/validated-field :hx-target "this"
{:errors (ferr :expense-accounts)} :hx-put (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit)}
(sel/raw (str (account-grid* request))))) (map-indexed (fn [i id] (com/hidden {:name (path->name2 :ids i) :value id})) ids)
"</div>") (com/modal
modal-card (sel/render "templates/transaction-edit/edit-modal.html" {:id "wizardmodal"}
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " invoices</div>") (com/single-modal-card
:side_panel nil {}
:body body [:div.p-2 (str "Bulk editing " (count ids) " invoices")]
:footer (str (footer* request))})] [:div.space-y-4.p-4
(sel/render->hiccup (com/validated-field
"templates/invoice-bulk-edit/edit-form.html" {:errors (ferr :expense-accounts)}
{:ids_hidden ids-hidden (account-grid* request))]
:form_attrs (sc/attrs->str {:hx-ext "response-targets" (footer* request)))])))
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit)})
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
(defn apply-new-account (defn apply-new-account
"bulk-edit-form-changed op: append a fresh (blank, Shared) expense-account row." "bulk-edit-form-changed op: append a fresh (blank, Shared) expense-account row."
@@ -1749,8 +1729,8 @@
(defn open-handler [request] (defn open-handler [request]
(modal-response (modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html" [:div {:id "transitioner" :class "flex-1"}
{:body (str (render-form request))}))) (render-form request)]))
(defn- render-form-response [request] (defn- render-form-response [request]
(html-response (render-form request) (html-response (render-form request)

View File

@@ -11,13 +11,11 @@
[auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.pos.common [auto-ap.ssr.pos.common
:refer [date-range-field*]] :refer [date-range-field*]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.svg :as svg] [auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils [auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema :refer [->db-id apply-middleware-to-all-handlers assert-schema
@@ -328,21 +326,21 @@
(->> items (keep :credit) (filter number?) (reduce + 0.0))) (->> items (keep :credit) (filter number?) (reduce + 0.0)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Render (Selmer): account typeahead, inline account cell (display/edit), ;; Render (Hiccup): account typeahead, inline account cell (display/edit),
;; the read-only auto rows, the editable manual rows, totals/balance. ;; the read-only auto rows, the editable manual rows, totals/balance.
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defn account-typeahead* [{:keys [name value client-id]}] (defn account-typeahead* [{:keys [name value client-id]}]
(sc/typeahead {:name name (com/typeahead {:name name
:id name :id name
:placeholder "Search..." :placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search) :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id {:client-id client-id
:purpose "invoice"}) :purpose "invoice"})
:value value :value value
:content-fn (fn [value] :content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})) client-id)))}))
(defn account-display-cell* (defn account-display-cell*
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell` "Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
@@ -352,47 +350,45 @@
account-name (when account-id account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id) (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))] client-id)))]
(str "<div class=\"account-cell flex items-center gap-2\">" [:div.account-cell.flex.items-center.gap-2
(str (sc/hidden {:name (item-field-name index :ledger-mapped/account) (com/hidden {:name (item-field-name index :ledger-mapped/account)
:value (or account-id "")})) :value (or account-id "")})
(if account-name (if account-name
(str "<span class=\"text-sm\">" (hu/escape-html account-name) "</span>") [:span.text-sm account-name]
(str (sel/hiccup->html (com/pill {:color :red} "Missing acct")))) (com/pill {:color :red} "Missing acct"))
(str (sc/a-icon-button {:class "p-1" (com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account) :hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell" :hx-target "closest .account-cell"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index :hx-vals (hx/json {:item-index index
:client-id client-id :client-id client-id
:current-account-id (or account-id "")})} :current-account-id (or account-id "")})}
svg/pencil)) svg/pencil)]))
"</div>")))
(defn account-edit-cell* (defn account-edit-cell*
"The account typeahead + check (save) / cancel buttons. Each swaps just the "The account typeahead + check (save) / cancel buttons. Each swaps just the
`.account-cell` back to the display cell." `.account-cell` back to the display cell."
[{:keys [index account-id client-id]}] [{:keys [index account-id client-id]}]
(str "<div class=\"account-cell flex flex-col gap-2\">" [:div.account-cell.flex.flex-col.gap-2
(str (account-typeahead* {:name (item-field-name index :ledger-mapped/account) (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value account-id :value account-id
:client-id client-id})) :client-id client-id})
"<div class=\"flex gap-1\">" [:div.flex.gap-1
(str (sc/a-icon-button {:class "p-1" (com/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account) :hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell" :hx-target "closest .account-cell"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest .account-cell" :hx-include "closest .account-cell"
:hx-vals (hx/json {:item-index index :client-id client-id})} :hx-vals (hx/json {:item-index index :client-id client-id})}
svg/check)) svg/check)
(str (sc/a-icon-button {:class "p-1" (com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account) :hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell" :hx-target "closest .account-cell"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index :hx-vals (hx/json {:item-index index
:client-id client-id :client-id client-id
:current-account-id (or account-id "")})} :current-account-id (or account-id "")})}
svg/x)) svg/x)]])
"</div></div>"))
(defn- auto-item-row* (defn- auto-item-row*
"A read-only auto item in its Debits/Credits column: category + inline-editable account "A read-only auto item in its Debits/Credits column: category + inline-editable account
@@ -400,57 +396,55 @@
[index item client-id] [index item client-id]
(let [side (item-side item) (let [side (item-side item)
amount (if (= side :debit) (:debit item) (:credit item))] amount (if (= side :debit) (:debit item) (:credit item))]
(str "<div class=\"flex items-center gap-2 text-sm\">" [:div.flex.items-center.gap-2.text-sm
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)})) (com/hidden {:name (item-field-name index :db/id) :value (:db/id item)})
(str (sc/hidden {:name (item-field-name index :sales-summary-item/category) (com/hidden {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)})) :value (:sales-summary-item/category item)})
"<span class=\"text-gray-500 flex-1\">" (hu/escape-html (str (:sales-summary-item/category item))) "</span>" [:span.text-gray-500.flex-1 (str (:sales-summary-item/category item))]
(str (account-display-cell* {:index index (account-display-cell* {:index index
:account-id (:ledger-mapped/account item) :account-id (:ledger-mapped/account item)
:client-id client-id})) :client-id client-id})
"<span class=\"ml-auto font-mono tabular-nums text-gray-900\">" (format "$%,.2f" (or amount 0.0)) "</span>" [:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (or amount 0.0))]]))
"</div>")))
(defn- manual-amount-input* [index field item] (defn- manual-amount-input* [index field item]
(sc/money-input {:name (item-field-name index field) (com/money-input {:name (item-field-name index field)
:value (get item field) :value (get item field)
:class "w-24 text-right font-mono tabular-nums" :class "w-24 text-right font-mono tabular-nums"
:placeholder (str/capitalize (clojure.core/name field)) :placeholder (str/capitalize (clojure.core/name field))
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-target "#summary-totals" :hx-target "#summary-totals"
:hx-select "#summary-totals" :hx-select "#summary-totals"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms" :hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"})) :hx-include "closest form"}))
(defn- manual-item-row* (defn- manual-item-row*
"An editable manual item: category + account typeahead + debit + credit money inputs + "An editable manual item: category + account typeahead + debit + credit money inputs +
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form." remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
[index item client-id] [index item client-id]
(str "<div class=\"manual-item-row flex items-center gap-2\">" [:div.manual-item-row.flex.items-center.gap-2
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)})) (com/hidden {:name (item-field-name index :db/id) :value (:db/id item)})
(str (sc/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"})) (com/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"})
(str (sc/validated-field (com/validated-field
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"} {:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
(sc/text-input {:name (item-field-name index :sales-summary-item/category) (com/text-input {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item) :value (:sales-summary-item/category item)
:placeholder "Category/Explanation"}))) :placeholder "Category/Explanation"}))
(str (sc/validated-field (com/validated-field
{:errors (item-field-errors index :ledger-mapped/account)} {:errors (item-field-errors index :ledger-mapped/account)}
(account-typeahead* {:name (item-field-name index :ledger-mapped/account) (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value (:ledger-mapped/account item) :value (:ledger-mapped/account item)
:client-id client-id}))) :client-id client-id}))
(str (manual-amount-input* index :debit item)) (manual-amount-input* index :debit item)
(str (manual-amount-input* index :credit item)) (manual-amount-input* index :credit item)
(str (sc/a-icon-button {:class "p-1 account-remove-action" (com/a-icon-button {:class "p-1 account-remove-action"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "remove-item" :row-index index}) :hx-vals (hx/json {:op "remove-item" :row-index index})
:hx-target "#summary-edit-form" :hx-target "#summary-edit-form"
:hx-select "#summary-edit-form" :hx-select "#summary-edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form"} :hx-include "closest form"}
svg/x)) svg/x)])
"</div>"))
(defn- totals* (defn- totals*
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total / "Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
@@ -461,41 +455,34 @@
tc (sum-credits items) tc (sum-credits items)
balanced? (dollars= td tc) balanced? (dollars= td tc)
delta (- td tc)] delta (- td tc)]
(str "<div class=\"border-t pt-2 mt-2 space-y-1\">" [:div.border-t.pt-2.mt-2.space-y-1
"<div class=\"flex justify-between text-sm font-semibold\"><span>Total</span>" [:div.flex.justify-between.text-sm.font-semibold
"<div class=\"flex gap-8\"><span class=\"font-mono\">" (format "$%,.2f" td) "</span>" [:span "Total"]
"<span class=\"font-mono\">" (format "$%,.2f" tc) "</span></div></div>" [:div.flex.gap-8
(if balanced? [:span.font-mono (format "$%,.2f" td)]
"<div class=\"text-sm text-emerald-700 font-semibold\">Balanced</div>" [:span.font-mono (format "$%,.2f" tc)]]]
(str "<div class=\"text-sm text-red-600 font-semibold flex justify-between\"><span>Unbalanced</span>" (if balanced?
"<span class=\"font-mono\">" (format "$%,.2f" (Math/abs delta)) " " [:div.text-sm.text-emerald-700.font-semibold "Balanced"]
(if (pos? delta) "Debit over" "Credit over") "</span></div>")) [:div.text-sm.text-red-600.font-semibold.flex.justify-between
"</div>"))) [:span "Unbalanced"]
[:span.font-mono (str (format "$%,.2f" (Math/abs delta)) " "
(if (pos? delta) "Debit over" "Credit over"))]])]))
(defn- new-item-button* [] (defn- new-item-button* []
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed) (com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "new-item"}) :hx-vals (hx/json {:op "new-item"})
:hx-target "#summary-edit-form" :hx-target "#summary-edit-form"
:hx-select "#summary-edit-form" :hx-select "#summary-edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form" :hx-include "closest form"
:color :secondary} :color :secondary}
"New Summary Item")) "New Summary Item"))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request] (defn- footer* [request]
(sel/raw [:div.flex.justify-end
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">" [:div.flex.items-baseline.gap-x-4
(form-errors-html (:errors (:form-errors request))) (com/form-errors {:errors (seq (:errors (:form-errors request)))})
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save")) (com/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save")]])
"</div></div>")))
(defn render-form (defn render-form
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the "Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
@@ -509,36 +496,39 @@
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed) manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
debit-rows (->> auto debit-rows (->> auto
(filter (fn [[_ it]] (= :debit (item-side it)))) (filter (fn [[_ it]] (= :debit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id))) (map (fn [[i it]] (auto-item-row* i it client-id))))
(apply str))
credit-rows (->> auto credit-rows (->> auto
(filter (fn [[_ it]] (= :credit (item-side it)))) (filter (fn [[_ it]] (= :credit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id))) (map (fn [[i it]] (auto-item-row* i it client-id))))
(apply str))
manual-rows (->> manual manual-rows (->> manual
(map (fn [[i it]] (manual-item-row* i it client-id))) (map (fn [[i it]] (manual-item-row* i it client-id))))]
(apply str)) [:form (merge {:id "summary-edit-form"}
body (sel/render "templates/sales-summary/summary-body.html" {:hx-ext "response-targets"
{:debit_rows debit-rows :hx-swap "outerHTML"
:credit_rows credit-rows :hx-target-400 "#form-errors .error-content"
:totals (totals* items) :hx-trigger "submit"
:manual_rows manual-rows :hx-target "this"
:new_item_button (str (new-item-button*))}) :hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)})
modal-card (sel/render "templates/transaction-edit/edit-modal.html" (com/hidden {:name "db/id" :value tx-id})
{:head "<div class=\"p-2\">Edit Summary</div>" (com/modal
:side_panel nil {:id "wizardmodal"}
:body body (com/single-modal-card
:footer (str (footer* request))})] {}
(sel/render->hiccup [:div.p-2 "Edit Summary"]
"templates/sales-summary/edit-form.html" [:div.space-y-4.p-2
{:db_id tx-id [:div.grid.grid-cols-2.gap-6
:form_attrs (sc/attrs->str {:hx-ext "response-targets" [:div
:hx-swap "outerHTML" [:div.font-semibold.text-sm.mb-2 "Debits"]
:hx-target-400 "#form-errors .error-content" [:div.space-y-1 debit-rows]]
:hx-trigger "submit" [:div
:hx-target "this" [:div.font-semibold.text-sm.mb-2 "Credits"]
:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)}) [:div.space-y-1 credit-rows]]]
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))})))) [:div {:id "summary-totals"} (totals* items)]
[:div.mt-4.border-t.pt-3
[:div.font-semibold.text-sm.mb-2 "Manual Items"]
[:div.space-y-2 {:id "manual-items"} manual-rows]
[:div.mt-2.flex.justify-center (new-item-button*)]]]
(footer* request)))])))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; State: derive the flat edit-state from the entity overlaid with the posted ;; State: derive the flat edit-state from the entity overlaid with the posted
@@ -646,7 +636,7 @@
idx (if (string? item-index) (Integer/parseInt item-index) item-index) idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))] account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response (html-response
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)}))))) (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)}))))
(defn save-item-account [request] (defn save-item-account [request]
(let [item-index (get-in request [:params "item-index"]) (let [item-index (get-in request [:params "item-index"])
@@ -655,14 +645,14 @@
account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)]) account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)])
account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))] account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))]
(html-response (html-response
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id}))))) (account-display-cell* {:index idx :account-id account-id :client-id client-id}))))
(defn cancel-item-account [request] (defn cancel-item-account [request]
(let [{:keys [item-index client-id current-account-id]} (:query-params request) (let [{:keys [item-index client-id current-account-id]} (:query-params request)
idx (if (string? item-index) (Integer/parseInt item-index) item-index) idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))] account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response (html-response
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)}))))) (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)}))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Open + submit ;; Open + submit
@@ -670,8 +660,8 @@
(defn open-handler [request] (defn open-handler [request]
(modal-response (modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html" [:div {:id "transitioner" :class "flex-1"}
{:body (str (render-form request))}))) (render-form request)]))
(defn- render-form-response [request] (defn- render-form-response [request]
(html-response (render-form request) (html-response (render-form request)

View File

@@ -1,43 +0,0 @@
(ns auto-ap.ssr.selmer
"Selmer rendering + the Hiccup<->Selmer interop bridge for the SSR form/wizard
migration (see .claude/skills/ssr-form-migration). Interactive, attribute-heavy
components render from Selmer templates with plain-HTML Alpine/HTMX attributes;
the bridge lets a Selmer template embed Hiccup output and lets a Selmer fragment
sit inside a Hiccup tree during the strangler transition.
Templates live under resources/templates/ and are referenced by classpath-relative
path, e.g. (render \"templates/components/typeahead.html\" ctx)."
(:require
[hiccup.util :as hu]
[hiccup2.core :as h2]
[selmer.parser :as selmer]))
(defn hiccup->html
"Render a Hiccup form to an HTML string so it can be embedded in a Selmer
context value and emitted with the |safe filter: {{ frag|safe }}."
[hiccup]
(str (h2/html {} hiccup)))
(defn raw
"Wrap an already-rendered HTML string (e.g. from `render`) so hiccup2 emits it
verbatim instead of escaping it. Use to drop a Selmer fragment into a Hiccup tree:
[:div (sel/raw (sel/render \"...\" ctx))]."
[^String html]
(hu/raw-string html))
(defn render
"Render a Selmer template file (classpath-relative path) with `ctx`, returning an
HTML string. Hiccup values in `ctx` should be pre-rendered via `hiccup->html` and
referenced with |safe in the template."
[template ctx]
(selmer/render-file template ctx))
(defn render-str
"Render a Selmer template given as a string (handy for tests/REPL)."
[template ctx]
(selmer/render template ctx))
(defn render->hiccup
"Render a Selmer template file and wrap the result for safe embedding in Hiccup."
[template ctx]
(raw (render template ctx)))

View File

@@ -11,17 +11,16 @@
[auto-ap.rule-matching :as rm] [auto-ap.rule-matching :as rm]
[auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]] [auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]] [auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel] [auto-ap.ssr.svg :as svg]
[auto-ap.ssr.transaction.common :refer [grid-page query-schema [auto-ap.ssr.transaction.common :refer [grid-page query-schema
selected->ids selected->ids
wrap-status-from-source]] wrap-status-from-source]]
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead-ctx [auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
location-select-ctx]] location-select*]]
[auto-ap.ssr.utils [auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id :refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
form-validation-error html-response main-transformer modal-response form-validation-error html-response main-transformer modal-response
@@ -65,6 +64,16 @@
is non-editable -- it is threaded separately by wrap-bulk-state." is non-editable -- it is threaded separately by wrap-bulk-state."
[:vendor :approval-status :accounts]) [:vendor :approval-status :accounts])
(def ^:private approval-status-options
"[value label] choices for the status <select>. Data, not markup -- the shared select
partial renders the <option>s from this (Django/Jinja widget convention: option labels
live in the data layer, never literal in the page template)."
[["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]])
(defn all-ids-not-locked (defn all-ids-not-locked
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)" "Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
[all-ids] [all-ids]
@@ -105,48 +114,80 @@
(-> request :clients first :db/id))) (-> request :clients first :db/id)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Render. Each route ends in a single sel/render call (render-form / open-handler) ;; Render (Hiccup). render-form builds the whole form from com/* components and the
;; from one nested view-model (form-ctx); all composition lives in the Selmer ;; shared single-modal-card chrome; account-row* renders one expense-account row. No
;; templates under transaction-bulk-code/ (form -> card extends components/modal-card ;; cursor, no wizard -- the flat :bulk-state drives everything and every interaction
;; -> head/body/footer -> account-grid -> account-row), pulling the shared sc/* ;; swaps the whole #bulk-code-form (whole-form swap doctrine).
;; component partials in via {% include %} + {% with %}. No HTML is built in Clojure.
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defn- account-row-vm (defn- account-row*
"Plain-data view-model for one expense-account row (rendered by account-row.html). Pure "One expense-account row (advanced grid) from a plain account map -- no cursor. The
data only: the row's index, the Alpine x-data, the db/id value, per-field error flags + location cell swaps just itself (#account-location-<index>); the remove button swaps
text, and the typeahead / location / money-input control contexts. All wiring (the row the whole #bulk-code-form. Built from com/* Hiccup components."
transitions, the location-cell swap, the remove button) and field names are built in
the template from `index` + the shared `urls`."
[{:keys [value client-id index errors]}] [{:keys [value client-id index errors]}]
(let [account-val (let [av (:account value)] (let [account-val (let [av (:account value)]
(if (map? av) (:db/id av) av))] (if (map? av) (:db/id av) av))
{:index index changed-url (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)]
:x_data (hx/json {:show (boolean (not (:new? value))) :accountId account-val}) (com/data-grid-row
:db_id_value (:db/id value) (-> {:class "account-row"
:account_has_error (boolean (seq (get-in errors [:accounts index :account]))) :id (str "account-row-" index)
:account_error (sc/errors-str (get-in errors [:accounts index :account])) :x-data (hx/json {:show (boolean (not (:new? value)))
:account (account-typeahead-ctx {:value account-val :accountId account-val})
:client-id client-id :data-key "show"
:name (account-field-name index :account) :x-ref "p"}
:x-model "accountId"}) hx/alpine-mount-then-appear)
:location_has_error (boolean (seq (get-in errors [:accounts index :location]))) (com/hidden {:name (account-field-name index :db/id)
:location_error (sc/errors-str (get-in errors [:accounts index :location])) :value (:db/id value)})
:location (location-select-ctx {:name (account-field-name index :location) (com/data-grid-cell
:account-location (:account/location (when (nat-int? account-val) {}
(dc/pull (dc/db conn) '[:account/location] account-val))) (com/validated-field
:client-locations (pull-attr (dc/db conn) :client/locations client-id) {:errors (get-in errors [:accounts index :account])}
:value (:location value)}) (account-typeahead* {:value account-val
:pct_has_error (boolean (seq (get-in errors [:accounts index :percentage]))) :client-id client-id
:pct_error (sc/errors-str (get-in errors [:accounts index :percentage])) :name (account-field-name index :account)
:pct (sc/money-input-ctx {:name (account-field-name index :percentage) :x-model "accountId"})))
:class "w-16" (com/data-grid-cell
:value (some-> (:percentage value) (* 100) long)})})) {:id (str "account-location-" index)}
(com/validated-field
(merge {:errors (get-in errors [:accounts index :location])}
{:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (account-field-name index :location)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-post changed-url
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"})
(location-select* {:name (account-field-name index :location)
:account-location (:account/location (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val)))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:location value)})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (get-in errors [:accounts index :percentage])}
(com/money-input {:name (account-field-name index :percentage)
:class "w-16"
:value (some-> (:percentage value) (* 100) long)})))
(com/data-grid-cell
{:class "align-top"}
(com/a-icon-button {:hx-post changed-url
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
svg/x)))))
(defn- form-ctx (defn render-form
"The whole bulk-code form as one nested view-model. render-form / open-handler each make "The whole bulk-code form as Hiccup -- no wizard, no cursor. The resolved (not-locked)
a single sel/render call with this; all composition (modal chrome, body, account grid, transaction id set rides in hidden ids[] fields so the selection survives
rows, footer, errors) happens in the Selmer templates -- no markup is built in Clojure." form-changed / submit posts. The vendor change, add/remove row, and submit all swap the
whole #bulk-code-form (whole-form swap doctrine)."
[request] [request]
(let [bulk-state (:bulk-state request) (let [bulk-state (:bulk-state request)
errors (or (:form-errors request) {}) errors (or (:form-errors request) {})
@@ -154,45 +195,77 @@
ids (:ids bulk-state) ids (:ids bulk-state)
accounts (vec (:accounts bulk-state)) accounts (vec (:accounts bulk-state))
vendor-val (:vendor bulk-state) vendor-val (:vendor bulk-state)
status-val (some-> (:approval-status bulk-state) name)] status-val (some-> (:approval-status bulk-state) name)
{:urls {:submit (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit) changed-url (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:changed (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)} submit-url (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)
:client_id client-id errors-str (when-let [e (seq (:errors errors))]
:head_count (count ids) (str/join ", " (filter string? e)))]
:ids ids [:form {:id "bulk-code-form"
:vendor {:has_error (boolean (seq (:vendor errors))) :hx-ext "response-targets"
:error (sc/errors-str (:vendor errors)) :hx-swap "outerHTML"
:ta (sc/typeahead-ctx {:name (path->name2 :vendor) :hx-target-400 "#form-errors .error-content"
:id (path->name2 :vendor) :hx-trigger "submit"
:error? (boolean (seq (:vendor errors))) :hx-target "this"
:placeholder "Search for vendor..." :hx-post submit-url}
:url (bidi/path-for ssr-routes/only-routes :vendor-search) (map-indexed (fn [i id] (com/hidden {:name (str "ids[" i "]") :value id})) ids)
:value vendor-val [:div {"@click.outside" "open=false" :id "bulkcodemodal"}
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})} (com/single-modal-card
:status {:value status-val {}
:has_error (boolean (seq (:approval-status errors))) [:div.p-2 (str "Bulk editing " (count ids) " transactions")]
:error (sc/errors-str (:approval-status errors))} [:div.space-y-4.p-4
:accounts {:error (sc/errors-str (:accounts errors)) [:div.grid.grid-cols-2.gap-4
:new_account (sc/a-button-ctx {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed) [:div {:hx-trigger "change"
:hx-vals (hx/json {:op "new-account"}) :hx-post changed-url
:hx-target "#bulk-code-form" :hx-vals (hx/json {:op "vendor-changed"})
:hx-select "#bulk-code-form" :hx-target "#bulk-code-form"
:hx-swap "outerHTML" :hx-select "#bulk-code-form"
:hx-include "closest form" :hx-swap "outerHTML"
:color :secondary} :hx-sync "this:replace"
"New account") :hx-include "closest form"}
:rows (map-indexed (fn [i a] (com/validated-field
(account-row-vm {:value a :client-id client-id :index i :errors errors})) {:label "Vendor" :errors (:vendor errors)}
accounts)} [:div.w-96
:errors_str (when-let [e (seq (:errors errors))] (com/typeahead {:name (path->name2 :vendor)
(str/join ", " (filter string? e))) :id (path->name2 :vendor)
:save (sc/button-ctx {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save")})) :placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
(defn render-form :value vendor-val
"Render the whole bulk-code form as a single Selmer render of form.html from the form :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})])]
view-model -- no Clojure-side string stitching, no wizard, no cursor." [:div
[request] (com/validated-field
(sel/render->hiccup "templates/transaction-bulk-code/form.html" (form-ctx request))) {:label "Status" :errors (:approval-status errors)}
(com/select {:name (path->name2 :approval-status)
:value status-val
:options approval-status-options}))]
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
[:div.group
[:div#account-entries.space-y-3
(com/data-grid
{:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]}
(map-indexed (fn [i a]
(account-row* {:value a :client-id client-id :index i :errors errors}))
accounts)
(com/data-grid-row
{:class "new-row"}
(com/data-grid-cell
{:colspan 4}
(com/a-button {:hx-post changed-url
:hx-vals (hx/json {:op "new-account"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account"))))]
(com/errors {:errors (:accounts errors)})]]]]
[:div.flex.justify-end
[:div.flex.items-baseline.gap-x-4
(com/form-errors {:errors (seq (:errors errors))})
(com/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save")]])]]))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation ;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
@@ -362,8 +435,7 @@
;; Return success modal ;; Return success modal
(html-response (html-response
(com/success-modal {:title "Transactions Coded"} (com/success-modal {:title "Transactions Coded"}
(sel/render->hiccup "templates/transaction-bulk-code/success-body.html" [:p (str "Successfully coded " (count ids) " transactions.")])
{:count (count ids)}))
:headers {"hx-trigger" "refreshTable, reset-selection"})))) :headers {"hx-trigger" "refreshTable, reset-selection"}))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@@ -371,11 +443,12 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defn open-handler (defn open-handler
"Initial modal open (GET). A single Selmer render of open.html (the #transitioner shell "Initial modal open (GET). The #transitioner shell the modal stack expects, wrapping the
that includes the whole form) from the form view-model." whole bulk-code form."
[request] [request]
(modal-response (modal-response
(sel/render->hiccup "templates/transaction-bulk-code/open.html" (form-ctx request)))) [:div {:id "transitioner" :class "flex-1"}
(render-form request)]))
(defn- render-form-response (defn- render-form-response
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors." "wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."

View File

@@ -20,12 +20,11 @@
[auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.inputs :as inputs] [auto-ap.ssr.components.inputs :as inputs]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :as helper] [auto-ap.ssr.grid-page-helper :as helper]
[auto-ap.ssr.transaction.common :refer [grid-page]] [auto-ap.ssr.transaction.common :refer [grid-page]]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel] [auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils [auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema :refer [->db-id apply-middleware-to-all-handlers assert-schema
check-allowance check-location-belongs entity-id check-allowance check-location-belongs entity-id
@@ -43,7 +42,7 @@
[iol-ion.tx :refer [random-tempid]] [iol-ion.tx :refer [random-tempid]]
[malli.core :as mc])) [malli.core :as mc]))
(declare render-full-form wrap-div) (declare render-full-form)
(def transaction-approval-status (def transaction-approval-status
{:transaction-approval-status/unapproved "Unapproved" {:transaction-approval-status/unapproved "Unapproved"
@@ -159,10 +158,10 @@
clientized (clientize-vendor vendor client-id)] clientized (clientize-vendor vendor client-id)]
(:vendor/default-account clientized)))) (:vendor/default-account clientized))))
(defn location-select-ctx (defn location-select*
"Plain-data context for templates/components/location-select.html: {:name :classes "The location <select> for an account row. Same options/selection/styling as com/select:
:options [{:value :label :selected}]}. Split out from location-select* so a fully when the account pins a location only that option is offered, otherwise Shared + the
template-driven account grid can stamp the same <select> from its own row loop." client's locations, defaulting to the current value (or the first option)."
[{:keys [name account-location client-locations value]}] [{:keys [name account-location client-locations value]}]
(let [options (cond account-location (let [options (cond account-location
[[account-location account-location]] [[account-location account-location]]
@@ -175,25 +174,14 @@
:else :else
[["Shared" "Shared"]]) [["Shared" "Shared"]])
selected (or value (ffirst options))] selected (or value (ffirst options))]
{:name name (com/select {:name name
:variant "w-full" :class "w-full"
:options (for [[v label] options] :value selected
{:value v :label label :selected (= v selected)})})) :options options})))
(defn location-select*
"The location <select> for an account row, rendered from a Selmer template
(templates/components/location-select.html) -- the first interactive modal component
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
plain HTML and embedded back into the Hiccup row via the interop bridge."
[params]
(sel/render->hiccup
"templates/components/location-select.html"
(location-select-ctx params)))
(defn- account-typeahead-params (defn- account-typeahead-params
"Shared param map for the account typeahead (account-search url + clientized label "Shared param map for the account typeahead (account-search url + clientized label
content-fn). Used by both account-typeahead* (renders) and account-typeahead-ctx content-fn) used by account-typeahead*."
(returns the typeahead context for a template-driven grid)."
[{:keys [name value client-id x-model]}] [{:keys [name value client-id x-model]}]
{:name name {:name name
:placeholder "Search..." :placeholder "Search..."
@@ -207,16 +195,9 @@
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))}) client-id)))})
(defn account-typeahead-ctx
"Plain-data typeahead context (sc/typeahead-ctx) for the account cell -- no flex-col
wrapper. Lets a template-driven row feed templates/components/typeahead.html via
{% with %} without re-deriving the url/content-fn."
[params]
(sc/typeahead-ctx (account-typeahead-params params)))
(defn account-typeahead* (defn account-typeahead*
[params] [params]
(wrap-div "flex flex-col" (sc/typeahead (account-typeahead-params params)))) [:div.flex.flex-col (com/typeahead (account-typeahead-params params))])
(def ^:dynamic *errors* (def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-form-schema paths (e.g. "Humanized form errors for the current render, keyed by edit-form-schema paths (e.g.
@@ -242,15 +223,6 @@
(defn- account-field-errors [index field] (defn- account-field-errors [index field]
(ferr :transaction/accounts index field)) (ferr :transaction/accounts index field))
(defn wrap-div
"Trivial structural wrapper <div class=...> around already-rendered HTML fragments.
Plain-string composition (not Hiccup) -- the substantive markup lives in Selmer
component templates; this just nests their output."
[class & body]
(sel/raw (str "<div class=\"" class "\">"
(apply str (map str (remove nil? body)))
"</div>")))
(defn simple-mode-fields* (defn simple-mode-fields*
"Renders the simple-mode account + location row and the toggle-to-advanced link. "Renders the simple-mode account + location row and the toggle-to-advanced link.
Must be called within a fc/start-form + fc/with-field :step-params context. Must be called within a fc/start-form + fc/with-field :step-params context.
@@ -285,36 +257,41 @@
:hx-select "#simple-account-location" :hx-select "#simple-account-location"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form"}] :hx-include "closest form"}]
(sel/render->hiccup [:div
"templates/transaction-edit/simple-mode.html" [:span
{:row_id_hidden (str (sc/hidden {:name (account-field-name 0 :db/id) :value row-id})) (com/hidden {:name (account-field-name 0 :db/id) :value row-id})
;; Selecting the account only affects the valid Location options, so the change ;; Selecting the account only affects the valid Location options, so the change
;; swaps just the #simple-account-location cell -- nothing else re-renders. ;; swaps just the #simple-account-location cell -- nothing else re-renders.
:account_field (str (sc/validated-field [:div.flex.gap-2.mt-2
{:label "Account" (com/validated-field
:errors (account-field-errors 0 :transaction-account/account)} {:label "Account"
(wrap-div "w-72" :errors (account-field-errors 0 :transaction-account/account)}
(account-typeahead* {:value account-val [:div.w-72
:client-id client-id (account-typeahead* {:value account-val
:name (account-field-name 0 :transaction-account/account) :client-id client-id
:x-model "simpleAccountId"})))) :name (account-field-name 0 :transaction-account/account)
:location_field (str (sc/validated-field :x-model "simpleAccountId"})])
(merge {:label "Location" [:div {:id "simple-account-location"}
:errors (account-field-errors 0 :transaction-account/location)} (com/validated-field
location-attrs) (merge {:label "Location"
(location-select* :errors (account-field-errors 0 :transaction-account/location)}
{:name (account-field-name 0 :transaction-account/location) location-attrs)
:account-location (:account/location account-id) (location-select*
:client-locations (pull-attr (dc/db conn) :client/locations client-id) {:name (account-field-name 0 :transaction-account/location)
:value location-val}))) :account-location (:account/location account-id)
:amount_hidden (str (sc/hidden {:name (account-field-name 0 :transaction-account/amount) :client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value total})) :value location-val}))]
:toggle_attrs (sc/attrs->str {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) (com/hidden {:name (account-field-name 0 :transaction-account/amount)
:hx-vals (hx/json {:op "toggle-mode"}) :value total})]]
:hx-include "closest form" [:div.mt-1
:hx-target "#edit-form" [:a (merge {:class "text-sm text-blue-600 hover:underline cursor-pointer"}
:hx-select "#edit-form" {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-swap "outerHTML"})}))) :hx-vals (hx/json {:op "toggle-mode"})
:hx-include "closest form"
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"})
"Switch to advanced mode"]]]))
(defn- manual-mode-initial (defn- manual-mode-initial
"Returns :simple or :advanced based on existing account row count." "Returns :simple or :advanced based on existing account row count."
@@ -351,7 +328,7 @@
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms" :hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}] :hx-include "closest form"}]
(sc/data-grid-row (com/data-grid-row
(-> {:class "account-row" (-> {:class "account-row"
:id (str "account-row-" index) :id (str "account-row-" index)
:x-data (hx/json {:show (boolean (not (:new? value))) :x-data (hx/json {:show (boolean (not (:new? value)))
@@ -359,19 +336,19 @@
:data-key "show" :data-key "show"
:x-ref "p"} :x-ref "p"}
hx/alpine-mount-then-appear) hx/alpine-mount-then-appear)
(sc/hidden {:name (account-field-name index :db/id) (com/hidden {:name (account-field-name index :db/id)
:value (:db/id value)}) :value (:db/id value)})
(sc/data-grid-cell (com/data-grid-cell
{} {}
(sc/validated-field (com/validated-field
{:errors (account-field-errors index :transaction-account/account)} {:errors (account-field-errors index :transaction-account/account)}
(account-typeahead* {:value account-val (account-typeahead* {:value account-val
:client-id client-id :client-id client-id
:name (account-field-name index :transaction-account/account) :name (account-field-name index :transaction-account/account)
:x-model "accountId"}))) :x-model "accountId"})))
(sc/data-grid-cell (com/data-grid-cell
{:id (str "account-location-" index)} {:id (str "account-location-" index)}
(sc/validated-field (com/validated-field
(merge {:errors (account-field-errors index :transaction-account/location)} (merge {:errors (account-field-errors index :transaction-account/location)}
location-attrs) location-attrs)
(location-select* {:name (account-field-name index :transaction-account/location) (location-select* {:name (account-field-name index :transaction-account/location)
@@ -379,23 +356,23 @@
(dc/pull (dc/db conn) '[:account/location] account-val))) (dc/pull (dc/db conn) '[:account/location] account-val)))
:client-locations (pull-attr (dc/db conn) :client/locations client-id) :client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:transaction-account/location value)}))) :value (:transaction-account/location value)})))
(sc/data-grid-cell (com/data-grid-cell
{} {}
(sc/validated-field (com/validated-field
{:errors (account-field-errors index :transaction-account/amount)} {:errors (account-field-errors index :transaction-account/amount)}
(if (= "%" amount-mode) (if (= "%" amount-mode)
(sc/text-input (assoc amount-attrs :type "number" :step "0.01")) (com/text-input (assoc amount-attrs :type "number" :step "0.01"))
(sc/money-input amount-attrs)))) (com/money-input amount-attrs))))
(sc/data-grid-cell (com/data-grid-cell
{:class "align-top"} {:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) (com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)}) :hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#edit-form" :hx-target "#edit-form"
:hx-select "#edit-form" :hx-select "#edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form" :hx-include "closest form"
:class "account-remove-action"} :class "account-remove-action"}
(sc/render "templates/components/svg-x.html" {})))))) svg/x)))))
(defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}] (defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}]
(html-response (location-select* {:name name (html-response (location-select* {:name name
@@ -428,9 +405,8 @@
(-> request :multi-form-state :snapshot :transaction/amount) (-> request :multi-form-state :snapshot :transaction/amount)
0.0)) 0.0))
total)] total)]
(sel/raw (str "<span" [:span (when-not (dollars= 0.0 balance) {:class "text-red-300"})
(when-not (dollars= 0.0 balance) " class=\"text-red-300\"") (format "$%,.2f" balance)]))
">" (format "$%,.2f" balance) "</span>"))))
(defn ->percentage [amount total] (defn ->percentage [amount total]
(when (and amount total (not= total 0)) (when (and amount total (not= total 0))
@@ -460,29 +436,27 @@
amounts))))) amounts)))))
(defn- bold-right [label] (defn- bold-right [label]
(sel/raw (str "<span class=\"font-bold text-right\">" label "</span>"))) [:span.font-bold.text-right label])
(defn account-totals-tbody* (defn account-totals-tbody*
"The separately-swappable totals <tbody> (Rule 4 target #account-totals)." "The separately-swappable totals <tbody> (Rule 4 target #account-totals)."
[request total] [request total]
(sel/render->hiccup [:tbody {:id "account-totals"}
"templates/transaction-edit/account-totals.html" (com/data-grid-row {:class "account-total-row"}
{:rows (str (com/data-grid-cell {})
(sc/data-grid-row {:class "account-total-row"} (com/data-grid-cell {:class "text-right"} (bold-right "TOTAL"))
(sc/data-grid-cell {}) (com/data-grid-cell {:id "total" :class "text-right"} (account-total* request))
(sc/data-grid-cell {:class "text-right"} (bold-right "TOTAL")) (com/data-grid-cell {}))
(sc/data-grid-cell {:id "total" :class "text-right"} (account-total* request)) (com/data-grid-row {:class "account-balance-row"}
(sc/data-grid-cell {})) (com/data-grid-cell {})
(sc/data-grid-row {:class "account-balance-row"} (com/data-grid-cell {:class "text-right"} (bold-right "BALANCE"))
(sc/data-grid-cell {}) (com/data-grid-cell {:id "balance" :class "text-right"} (account-balance* request))
(sc/data-grid-cell {:class "text-right"} (bold-right "BALANCE")) (com/data-grid-cell {}))
(sc/data-grid-cell {:id "balance" :class "text-right"} (account-balance* request)) (com/data-grid-row {:class "account-grand-total-row"}
(sc/data-grid-cell {})) (com/data-grid-cell {})
(sc/data-grid-row {:class "account-grand-total-row"} (com/data-grid-cell {:class "text-right"} (bold-right "TRANSACTION TOTAL"))
(sc/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} (format "$%,.2f" total))
(sc/data-grid-cell {:class "text-right"} (bold-right "TRANSACTION TOTAL")) (com/data-grid-cell {}))])
(sc/data-grid-cell {:class "text-right"} (format "$%,.2f" total))
(sc/data-grid-cell {})))}))
(defn account-grid-body* [request] (defn account-grid-body* [request]
(let [snapshot (-> request :multi-form-state :snapshot) (let [snapshot (-> request :multi-form-state :snapshot)
@@ -496,22 +470,22 @@
(:transaction/accounts snapshot) (:transaction/accounts snapshot)
[]))] []))]
(apply (apply
sc/data-grid com/data-grid
{:headers [(sc/data-grid-header {} "Account") {:headers [(com/data-grid-header {} "Account")
(sc/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-32"} "Location")
(sc/data-grid-header {:class "w-16"} (com/data-grid-header {:class "w-16"}
(sc/radio-card {:options [{:value "$" :content "$"} (com/radio-card {:options [{:value "$" :content "$"}
{:value "%" :content "%"}] {:value "%" :content "%"}]
:value amount-mode :value amount-mode
:name "amount-mode" :name "amount-mode"
:orientation :horizontal :orientation :horizontal
:hx-vals (hx/json {:op "toggle-amount-mode"}) :hx-vals (hx/json {:op "toggle-amount-mode"})
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#edit-form" :hx-target "#edit-form"
:hx-select "#edit-form" :hx-select "#edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form"})) :hx-include "closest form"}))
(sc/data-grid-header {:class "w-16"})] (com/data-grid-header {:class "w-16"})]
:footer-tbody (account-totals-tbody* request total)} :footer-tbody (account-totals-tbody* request total)}
(concat (concat
(map-indexed (map-indexed
@@ -521,17 +495,17 @@
:amount-mode amount-mode :amount-mode amount-mode
:index index})) :index index}))
accounts) accounts)
[(sc/data-grid-row [(com/data-grid-row
{:class "new-row"} {:class "new-row"}
(sc/data-grid-cell {:colspan 4} (com/data-grid-cell {:colspan 4}
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) (com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "new-account"}) :hx-vals (hx/json {:op "new-account"})
:hx-target "#edit-form" :hx-target "#edit-form"
:hx-select "#edit-form" :hx-select "#edit-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form" :hx-include "closest form"
:color :secondary} :color :secondary}
"New account")))])))) "New account")))]))))
(defn manual-coding-section* (defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab. "Renders the vendor field + account/location section for the manual tab.
@@ -545,49 +519,44 @@
(seq (:transaction/accounts snapshot))) (seq (:transaction/accounts snapshot)))
row-count (count all-accounts) row-count (count all-accounts)
vendor-val (:transaction/vendor step-params) vendor-val (:transaction/vendor step-params)
toggle-attrs (fn [] (sc/attrs->str {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) toggle-attrs {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "toggle-mode"}) :hx-vals (hx/json {:op "toggle-mode"})
:hx-include "closest form" :hx-include "closest form"
:hx-target "#edit-form" :hx-target "#edit-form"
:hx-select "#edit-form" :hx-select "#edit-form"
:hx-swap "outerHTML"}))] :hx-swap "outerHTML"}]
(sel/render->hiccup [:div {:id "manual-coding-section"}
"templates/transaction-edit/manual-coding.html" (com/hidden {:name "mode" :value (name mode)})
{:mode_hidden (str (sc/hidden {:name "mode" :value (name mode)})) [:div {:hx-trigger "change"
:vendor_changed_attrs (sc/attrs->str {:hx-trigger "change" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) :hx-vals (hx/json {:op "vendor-changed"})
:hx-vals (hx/json {:op "vendor-changed"}) :hx-target "#edit-form"
:hx-target "#edit-form" :hx-select "#edit-form"
:hx-select "#edit-form" :hx-swap "outerHTML"
:hx-swap "outerHTML" :hx-sync "this:replace"
:hx-sync "this:replace" :hx-include "closest form"}
:hx-include "closest form"}) (com/validated-field
:vendor_field (str (sc/validated-field {:label "Vendor" :errors (ferr :transaction/vendor)}
{:label "Vendor" :errors (ferr :transaction/vendor)} [:div.w-96
(wrap-div "w-96" (com/typeahead {:name (fname :transaction/vendor)
(sc/typeahead {:name (fname :transaction/vendor) :class "w-96"
:error? (boolean (seq (ferr :transaction/vendor))) :placeholder "Search..."
:class "w-96" :url (bidi/path-for ssr-routes/only-routes :vendor-search)
:placeholder "Search..." :value vendor-val
:url (bidi/path-for ssr-routes/only-routes :vendor-search) :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})])]
:value vendor-val (if (= mode :simple)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))) [:div {:x-data (hx/json {:simpleAccountId (let [av (-> (first all-accounts) :transaction-account/account)]
:is_simple (= mode :simple) (if (map? av) (:db/id av) av))})}
:simple_xdata (when (= mode :simple) (simple-mode-fields* request)]
(hx/json {:simpleAccountId (let [av (-> (first all-accounts) :transaction-account/account)] [:div
(if (map? av) (:db/id av) av))})) (when (<= row-count 1)
:simple_mode (when (= mode :simple) (str (simple-mode-fields* request))) [:div.mb-2
:toggle_link (when (and (not= mode :simple) (<= row-count 1)) [:a (merge {:class "text-sm text-blue-600 hover:underline cursor-pointer"} toggle-attrs)
(str (wrap-div "mb-2" "Switch to simple mode"]])
(sel/raw (str "<a class=\"text-sm text-blue-600 hover:underline cursor-pointer\"" (com/validated-field
(toggle-attrs) {:errors (ferr :transaction/accounts)}
">Switch to simple mode</a>"))))) [:div {:id "account-grid-body"}
:accounts_field (when (not= mode :simple) (account-grid-body* request)])])]))
(str (sc/validated-field
{:errors (ferr :transaction/accounts)}
(sel/raw (str "<div id=\"account-grid-body\">"
(str (account-grid-body* request))
"</div>")))))})))
(defn apply-toggle-amount-mode (defn apply-toggle-amount-mode
"edit-form-changed op: convert account amounts between $ and % and record the new mode." "edit-form-changed op: convert account amounts between $ and % and record the new mode."
@@ -607,17 +576,35 @@
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode)))) (assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))))
(defn transaction-details-panel [tx] (defn transaction-details-panel [tx]
(sel/render->hiccup [:div.p-4.space-y-4
"templates/transaction-edit/details-panel.html" [:h3.text-sm.font-semibold.text-gray-900.uppercase.tracking-wider "Details"]
{:amount (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0))) [:div.space-y-3
:date (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date)) [:div
:bank_account (or (-> tx :transaction/bank-account :bank-account/name) "-") [:div.text-xs.font-medium.text-gray-500 "Amount"]
:post_date (some-> tx :transaction/post-date coerce/to-date-time (atime/unparse-local atime/normal-date)) [:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0)))]]
:description_original (or (:transaction/description-original tx) "No original description") [:div
:description_simple (or (:transaction/description-simple tx) "-") [:div.text-xs.font-medium.text-gray-500 "Date"]
:check_number (or (:transaction/check-number tx) "-") [:div.text-sm.text-gray-900 (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
:status (or (some-> tx :transaction/status) "-") [:div
:type (or (some-> tx :transaction/type) "-")})) [:div.text-xs.font-medium.text-gray-500 "Bank Account"]
[:div.text-sm.text-gray-900 (or (-> tx :transaction/bank-account :bank-account/name) "-")]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Post Date"]
[:div.text-sm.text-gray-900 (some-> tx :transaction/post-date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Description"]
[:div.text-sm.text-gray-900.truncate.cursor-help
{:title (or (:transaction/description-original tx) "No original description")}
(or (:transaction/description-simple tx) "-")]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Check Number"]
[:div.text-sm.text-gray-900 (or (:transaction/check-number tx) "-")]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Status"]
[:div.text-sm.text-gray-900 (or (some-> tx :transaction/status) "-")]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Transaction Type"]
[:div.text-sm.text-gray-900 (or (some-> tx :transaction/type) "-")]]]])
(defn get-available-payments [request] (defn get-available-payments [request]
(let [tx-id (or (get-in request [:form-params :transaction-id]) (let [tx-id (or (get-in request [:form-params :transaction-id])
@@ -651,38 +638,39 @@
(d-invoices/get-by-id invoice-id)))))) (d-invoices/get-by-id invoice-id))))))
(defn- panel-wrap [inner] (defn- panel-wrap [inner]
(sel/raw (str "<div>" (str inner) "</div>"))) [:div inner])
(defn- panel-empty* [message] (defn- panel-empty* [message]
(sel/render->hiccup "templates/transaction-edit/panel-empty.html" {:message message})) [:div.text-center.py-4.text-gray-500 message])
(defn- panel-list* [{:keys [heading action-hidden prompt radio]}] (defn- panel-list* [{:keys [heading action-hidden prompt radio]}]
(sel/render->hiccup "templates/transaction-edit/panel-list.html" [:div
{:heading heading [:h3.text-lg.font-bold.mb-4 heading]
:action_hidden (str action-hidden) action-hidden
:prompt prompt [:div.space-y-2
:radio (str radio)})) [:label.block.text-sm.font-medium.mb-1 prompt]
radio]])
(defn- invoice-group-content [match-group] (defn- invoice-group-content [match-group]
(sel/raw (apply str (for [invoice match-group] (for [invoice match-group]
(sel/render "templates/transaction-edit/invoice-option.html" [:div.ml-3
{:number (:invoice/invoice-number invoice) [:span.block.text-sm.font-medium (:invoice/invoice-number invoice)]
:vendor (-> invoice :invoice/vendor :vendor/name) [:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
:date (some-> invoice :invoice/date coerce/to-date-time (atime/unparse-local atime/normal-date)) [:span.block.text-sm.text-gray-500 (some-> invoice :invoice/date coerce/to-date-time (atime/unparse-local atime/normal-date))]
:amount (format "$%.2f" (:invoice/outstanding-balance invoice))}))))) [:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))
(defn autopay-invoices-view [request] (defn autopay-invoices-view [request]
(let [invoice-matches (get-available-autopay-invoices request)] (let [invoice-matches (get-available-autopay-invoices request)]
(panel-wrap (panel-wrap
(if (seq invoice-matches) (if (seq invoice-matches)
(panel-list* {:heading "Available Autopay Invoices" (panel-list* {:heading "Available Autopay Invoices"
:action-hidden (sc/hidden {:name "action" :value "link-autopay-invoices" :form ""}) :action-hidden (com/hidden {:name "action" :value "link-autopay-invoices" :form ""})
:prompt "Select an autopay invoice to apply:" :prompt "Select an autopay invoice to apply:"
:radio (sc/radio-card {:options (for [match-group invoice-matches] :radio (com/radio-card {:options (for [match-group invoice-matches]
{:value (pr-str (map :db/id match-group)) {:value (pr-str (map :db/id match-group))
:content (invoice-group-content match-group)}) :content (invoice-group-content match-group)})
:name (fname :autopay-invoice-ids) :name (fname :autopay-invoice-ids)
:width "w-full"})}) :width "w-full"})})
(panel-empty* "No matching autopay invoices available for this transaction."))))) (panel-empty* "No matching autopay invoices available for this transaction.")))))
(defn get-available-unpaid-invoices [request] (defn get-available-unpaid-invoices [request]
@@ -705,13 +693,13 @@
(panel-wrap (panel-wrap
(if (seq invoice-matches) (if (seq invoice-matches)
(panel-list* {:heading "Available Unpaid Invoices" (panel-list* {:heading "Available Unpaid Invoices"
:action-hidden (sc/hidden {:name "action" :value "link-unpaid-invoices" :form ""}) :action-hidden (com/hidden {:name "action" :value "link-unpaid-invoices" :form ""})
:prompt "Select an unpaid invoice to apply:" :prompt "Select an unpaid invoice to apply:"
:radio (sc/radio-card {:options (for [match-group invoice-matches] :radio (com/radio-card {:options (for [match-group invoice-matches]
{:value (pr-str (map :db/id match-group)) {:value (pr-str (map :db/id match-group))
:content (invoice-group-content match-group)}) :content (invoice-group-content match-group)})
:name (fname :unpaid-invoice-ids) :name (fname :unpaid-invoice-ids)
:width "w-full"})}) :width "w-full"})})
(panel-empty* "No matching unpaid invoices available for this transaction."))))) (panel-empty* "No matching unpaid invoices available for this transaction.")))))
(defn get-available-rules [request] (defn get-available-rules [request]
@@ -745,14 +733,15 @@
(panel-wrap (panel-wrap
(if (seq matching-rules) (if (seq matching-rules)
(panel-list* {:heading "Matching Transaction Rules" (panel-list* {:heading "Matching Transaction Rules"
:action-hidden (sc/hidden {:name (fname :action) :value "apply-rule" :form ""}) :action-hidden (com/hidden {:name (fname :action) :value "apply-rule" :form ""})
:prompt "Select a rule to apply:" :prompt "Select a rule to apply:"
:radio (sc/radio-card {:options (for [{:keys [:db/id :transaction-rule/note :transaction-rule/description]} matching-rules] :radio (com/radio-card {:options (for [{:keys [:db/id :transaction-rule/note :transaction-rule/description]} matching-rules]
{:value id {:value id
:content (sel/render->hiccup "templates/transaction-edit/rule-option.html" :content [:div.ml-3
{:note note :description description})}) [:span.block.text-sm.font-medium note]
:name (fname :rule-id) [:span.block.text-sm.text-gray-500 description]]})
:width "w-full"})}) :name (fname :rule-id)
:width "w-full"})})
(panel-empty* "No matching rules found for this transaction."))))) (panel-empty* "No matching rules found for this transaction.")))))
(defn payment-matches-view [request] (defn payment-matches-view [request]
@@ -770,43 +759,42 @@
:payment/vendor [:vendor/name]}] :payment/vendor [:vendor/name]}]
(-> tx :transaction/payment :db/id))] (-> tx :transaction/payment :db/id))]
(sel/render->hiccup [:div {:id "payment-matches"}
"templates/transaction-edit/payment-matches.html" (if (and payment (:db/id payment))
{:inner [:div.my-4.p-4.bg-blue-50.rounded
(str [:h3.text-lg.font-bold.mb-2 "Linked Payment"
(if (and payment (:db/id payment)) (com/a-icon-button {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
(sel/render->hiccup {:exact-match-id (:db/id payment)})}
"templates/transaction-edit/linked-payment.html" svg/external-link)]
{:external_link (str (sc/a-icon-button {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page) [:div.space-y-2
{:exact-match-id (:db/id payment)})} [:div.flex.justify-between [:div.font-medium "Payment #"] [:div (:payment/invoice-number payment)]]
(sc/render "templates/components/svg-external-link.html" {}))) [:div.flex.justify-between [:div.font-medium "Vendor"] [:div (-> payment :payment/vendor :vendor/name)]]
:number (:payment/invoice-number payment) [:div.flex.justify-between [:div.font-medium "Amount"] [:div (some->> (:payment/amount payment) (format "$%.2f"))]]
:vendor (-> payment :payment/vendor :vendor/name) [:div.flex.justify-between [:div.font-medium "Status"] [:div (some-> payment :payment/status name)]]
:amount (some->> (:payment/amount payment) (format "$%.2f")) [:div.flex.justify-between [:div.font-medium "Date"] [:div (some-> payment :payment/date (atime/unparse-local atime/normal-date))]]
:status (some-> payment :payment/status name) (com/hidden {:name (fname :payment-id) :value (:db/id payment)})
:date (some-> payment :payment/date (atime/unparse-local atime/normal-date)) [:div (merge {:class "mt-4"}
:payment_id_hidden (str (sc/hidden {:name (fname :payment-id) :value (:db/id payment)})) {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
:unlink_attrs (sc/attrs->str {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment) :hx-trigger "unlinkPayment"
:hx-trigger "unlinkPayment" :hx-target "#payment-matches"
:hx-target "#payment-matches" :hx-include "closest form"
:hx-include "closest form" :hx-swap "outerHTML"
:hx-swap "outerHTML" :hx-confirm "Are you sure you want to unlink this payment?"})
:hx-confirm "Are you sure you want to unlink this payment?"}) (com/a-button {:color :red :size :small "@click" "$dispatch('unlinkPayment')"}
:unlink_button (str (sc/a-button {:color :red :size :small "@click" "$dispatch('unlinkPayment')"} "Unlink Payment")]]]
"Unlink Payment"))}) (if (seq payments)
(if (seq payments) (panel-list* {:heading "Available Payments"
(panel-list* {:heading "Available Payments" :action-hidden ""
:action-hidden "" :prompt "Select a payment to match:"
:prompt "Select a payment to match:" :radio (com/radio-card {:options (for [payment payments]
:radio (sc/radio-card {:options (for [payment payments] {:value (:db/id payment)
{:value (:db/id payment) :content (str (:payment/invoice-number payment) " - "
:content (str (:payment/invoice-number payment) " - " (-> payment :payment/vendor :vendor/name)
(-> payment :payment/vendor :vendor/name) " - Amount: $" (format "%.2f" (:payment/amount payment))
" - Amount: $" (format "%.2f" (:payment/amount payment)) " • Date: " (some-> payment :payment/date coerce/to-date-time (atime/unparse-local atime/normal-date)))})
" • Date: " (some-> payment :payment/date coerce/to-date-time (atime/unparse-local atime/normal-date)))}) :name (fname :payment-id)
:name (fname :payment-id) :width "w-full"})})
:width "w-full"})}) (panel-empty* "No matching payments available for this transaction.")))]))
(panel-empty* "No matching payments available for this transaction."))))})))
(defn count-payment-matches [request] (defn count-payment-matches [request]
(count (get-available-payments request))) (count (get-available-payments request)))
@@ -821,18 +809,18 @@
(count (get-available-rules request))) (count (get-available-rules request)))
(defn- tab-button [{:keys [active value badge-count disabled? relative?] :or {relative? true}} label] (defn- tab-button [{:keys [active value badge-count disabled? relative?] :or {relative? true}} label]
(sc/button-group-button (com/button-group-button
(cond-> {"@click" (str "activeForm = '" active "'") (cond-> {"@click" (str "activeForm = '" active "'")
:value value :value value
":class" (str "{ '!bg-primary-200 text-primary-800': activeForm === '" active "'}")} ":class" (str "{ '!bg-primary-200 text-primary-800': activeForm === '" active "'}")}
relative? (assoc :class "relative") relative? (assoc :class "relative")
disabled? (assoc ":disabled" "!canChange")) disabled? (assoc ":disabled" "!canChange"))
(when (and badge-count (> badge-count 0)) (when (and badge-count (> badge-count 0))
(sc/badge {:color "green"} (str badge-count))) (com/badge {:color "green"} (str badge-count)))
label)) label))
(defn- tabs* [request] (defn- tabs* [request]
(sc/button-group (com/button-group
{:name "method"} {:name "method"}
(tab-button {:active "link-payment" :value "payment" (tab-button {:active "link-payment" :value "payment"
:badge-count (count-payment-matches request)} "Link to payment") :badge-count (count-payment-matches request)} "Link to payment")
@@ -849,57 +837,57 @@
v (:transaction/approval-status step-params) v (:transaction/approval-status step-params)
current-value (name (or (if (map? v) (:db/ident v) v) current-value (name (or (if (map? v) (:db/ident v) v)
:transaction-approval-status/unapproved))] :transaction-approval-status/unapproved))]
(sc/validated-field (com/validated-field
{:label "Status" :errors (ferr :transaction/approval-status)} {:label "Status" :errors (ferr :transaction/approval-status)}
(sel/render->hiccup [:div {:x-data (hx/json {:approvalStatus current-value})}
"templates/transaction-edit/approval-status.html" (com/hidden {:name (fname :transaction/approval-status)
{:x_data (hx/json {:approvalStatus current-value}) :value current-value ":value" "approvalStatus"})
:status_hidden (str (sc/hidden {:name (fname :transaction/approval-status) [:div.inline-flex.rounded-md.shadow-sm {:role "group"}
:value current-value ":value" "approvalStatus"})) (com/button-group-button {"@click" "approvalStatus = 'approved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }" :class "rounded-l-lg"} "Approved")
:buttons (str (com/button-group-button {"@click" "approvalStatus = 'unapproved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }" :class "rounded-r-lg"} "Unapproved")
(sc/button-group-button {"@click" "approvalStatus = 'approved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }" :class "rounded-l-lg"} "Approved") (com/button-group-button {"@click" "approvalStatus = 'suppressed'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }" :class "rounded-r-lg"} "Client Review")]])))
(sc/button-group-button {"@click" "approvalStatus = 'unapproved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }" :class "rounded-r-lg"} "Unapproved")
(sc/button-group-button {"@click" "approvalStatus = 'suppressed'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }" :class "rounded-r-lg"} "Client Review"))}))))
(defn- links-body* [request mode] (defn- links-body* [request mode]
(let [step-params (-> request :multi-form-state :step-params) (let [step-params (-> request :multi-form-state :step-params)
payment? (:transaction/payment (:entity request)) payment? (:transaction/payment (:entity request))
action-str (some-> (:action step-params) name)] action-str (some-> (:action step-params) name)
(sel/render->hiccup enter-attrs {:x-transition:enter "transition ease-out duration-500"
"templates/transaction-edit/links-body.html" :x-transition:enter-start "opacity-0 transform scale-95"
{:memo_field (str (sc/validated-field :x-transition:enter-end "opacity-100 transform scale-100"}]
{:label "Memo" :errors (ferr :transaction/memo)} [:div.space-y-1
(wrap-div "w-96" [:div
(sc/text-input {:value (:transaction/memo step-params) (com/validated-field
:name (fname :transaction/memo) {:label "Memo" :errors (ferr :transaction/memo)}
:id "edit-memo" [:div.w-96
:error? (ferr :transaction/memo) (com/text-input {:value (:transaction/memo step-params)
:placeholder "Optional note"})))) :name (fname :transaction/memo)
:x_data (hx/json {:activeForm (if payment? "link-payment" (or action-str "manual")) :id "edit-memo"
:canChange (boolean (not payment?))}) :error? (ferr :transaction/memo)
:action_hidden (str (sc/hidden {:name (fname :action) :value action-str ":value" "activeForm"})) :placeholder "Optional note"})])
:tabs (str (tabs* request)) [:div {:x-data (hx/json {:activeForm (if payment? "link-payment" (or action-str "manual"))
:panel_payment (str (payment-matches-view request)) :canChange (boolean (not payment?))})
:panel_unpaid (str (unpaid-invoices-view request)) "@unlinked" "canChange=true"}
:panel_autopay (str (autopay-invoices-view request)) [:div.flex.space-x-2.mb-4
:panel_rule (str (transaction-rules-view request)) (com/hidden {:name (fname :action) :value action-str ":value" "activeForm"})
:panel_manual (str (manual-coding-section* mode request) (tabs* request)]
(approval-status* request))}))) [:div (merge {:x-show "activeForm === 'link-payment'"} enter-attrs)
(payment-matches-view request)]
(defn- form-errors-html [errors] [:div (merge {:x-show "activeForm === 'link-unpaid-invoices'"} enter-attrs)
(str "<div id=\"form-errors\">" (unpaid-invoices-view request)]
(when (seq errors) [:div (merge {:x-show "activeForm === 'link-autopay-invoices'"} enter-attrs)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">" (autopay-invoices-view request)]
(str/join ", " (filter string? errors)) [:div (merge {:x-show "activeForm === 'apply-rule'"} enter-attrs)
"</p></span>")) (transaction-rules-view request)]
"</div>")) [:div (merge {:x-show "activeForm === 'manual'"} enter-attrs)
[:div
(manual-coding-section* mode request)
(approval-status* request)]]]]]))
(defn- footer* [request] (defn- footer* [request]
(sel/raw [:div.flex.justify-end
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">" [:div.flex.items-baseline.gap-x-4
(form-errors-html (:errors (:form-errors request))) (com/form-errors {:errors (seq (:errors (:form-errors request)))})
(str (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Done")) (com/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Done")]])
"</div></div>")))
(defn render-form (defn render-form
"Renders the whole plain edit form (no wizard). Binds *errors* from the request's "Renders the whole plain edit form (no wizard). Binds *errors* from the request's
@@ -914,21 +902,20 @@
;; Preserve an explicit mode choice; fall back to the row-count heuristic only ;; Preserve an explicit mode choice; fall back to the row-count heuristic only
;; on initial open. ;; on initial open.
mode (keyword (or (:mode step-params) (name (manual-mode-initial snapshot)))) mode (keyword (or (:mode step-params) (name (manual-mode-initial snapshot))))
modal-card (sel/render "templates/transaction-edit/edit-modal.html" modal-card (com/single-modal-card
{:head "<div class=\"p-2\">Edit Transaction</div>" {:side-panel (transaction-details-panel tx)}
:side_panel (str (transaction-details-panel tx)) [:div.p-2 "Edit Transaction"]
:body (str (links-body* request mode)) (links-body* request mode)
:footer (str (footer* request))})] (footer* request))]
(sel/render->hiccup [:form (merge {:id "edit-form"}
"templates/transaction-edit/edit-form.html" {:hx-ext "response-targets"
{:db_id (:db/id snapshot) :hx-swap "outerHTML"
:form_attrs (sc/attrs->str {:hx-ext "response-targets" :hx-target-400 "#form-errors .error-content"
:hx-swap "outerHTML" :hx-trigger "submit"
:hx-target-400 "#form-errors .error-content" :hx-target "this"
:hx-trigger "submit" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-submit)})
:hx-target "this" (com/hidden {:name "db/id" :value (:db/id snapshot)})
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-submit)}) (com/modal {:id "editmodal"} modal-card)])))
:modal (str (sc/modal {:id "editmodal"} (sel/raw modal-card)))}))))
(defmulti save-handler (fn [request] (defmulti save-handler (fn [request]
(-> request :multi-form-state :snapshot :action))) (-> request :multi-form-state :snapshot :action)))
@@ -1396,8 +1383,8 @@
by the modal stack." by the modal stack."
[request] [request]
(modal-response (modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html" [:div {:id "transitioner" :class "flex-1"}
{:body (str (render-form request))}))) (render-form request)]))
(defn submit-edit (defn submit-edit
"Validates the merged record against edit-form-schema (field-level errors surface via "Validates the merged record against edit-form-schema (field-level errors surface via

View File

@@ -4,7 +4,6 @@ const plugin = require('tailwindcss/plugin');
module.exports = { module.exports = {
darkMode: "class", darkMode: "class",
content: ["./src/**/*.{cljs,clj,cljc}", content: ["./src/**/*.{cljs,clj,cljc}",
"./resources/templates/**/*.html",
"./node_modules/flowbite/**/*.js"], "./node_modules/flowbite/**/*.js"],
theme: { theme: {
extend: { extend: {

View File

@@ -1,36 +0,0 @@
(ns auto-ap.ssr.selmer-test
(:require
[auto-ap.ssr.selmer :as sut]
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[hiccup2.core :as h2]))
(deftest hiccup->html
(testing "renders a Hiccup form to an HTML string"
(is (= "<span class=\"label\">A &amp; B</span>"
(sut/hiccup->html [:span.label "A & B"])))))
(deftest selmer-embeds-hiccup
(testing "a Hiccup component renders inside a Selmer template via |safe"
(let [frag (sut/hiccup->html [:span.badge "from hiccup"])
out (sut/render-str "<div>{{frag|safe}}</div>" {:frag frag})]
(is (str/includes? out "<span class=\"badge\">from hiccup</span>"))
;; without |safe the markup would be escaped; |safe keeps it verbatim
(is (not (str/includes? out "&lt;span"))))))
(deftest selmer-fragment-inside-hiccup
(testing "a Selmer fragment renders inside a Hiccup tree without double-escaping"
(let [sel (sut/render-str "<a href=\"{{url}}\">{{label}}</a>" {:url "/x" :label "Go"})
out (str (h2/html {} [:div (sut/raw sel)]))]
(is (= "<div><a href=\"/x\">Go</a></div>" out)))))
(deftest render-file-from-classpath
(testing "render-file resolves a template under resources/templates and keeps plain-HTML Alpine/HTMX attrs"
(let [out (sut/render "templates/interop-smoke.html"
{:title "Interop OK"
:hiccup_frag (sut/hiccup->html [:span.badge "from hiccup"])})]
(is (str/includes? out "Interop OK"))
(is (str/includes? out "from hiccup"))
;; plain-HTML attributes (the whole point of Selmer) survive unambiguously
(is (str/includes? out "x-model=\"value.value\""))
(is (str/includes? out "tippy?.show()")))))