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:
@@ -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).
|
||||||
|
|||||||
@@ -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*.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 `"`/`'` 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.)
|
|
||||||
@@ -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 1–3) 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).
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
|
|
||||||
</div>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
|
|
||||||
</button>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<div class="inline-flex rounded-md shadow-sm"
|
|
||||||
role="group"
|
|
||||||
hx-on:click="this.querySelector("input").value = event.target.value; this.querySelector("input").dispatchEvent(new Event('change', {bubbles: true}));">
|
|
||||||
<input type="hidden" name="{{ name }}">
|
|
||||||
{{ body|safe }}
|
|
||||||
</div>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"
|
|
||||||
{{ attrs|safe }}>{{ body|safe }}
|
|
||||||
</td>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}
|
|
||||||
</tr>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 }}>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}
|
|
||||||
</div>
|
|
||||||
@@ -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 }}>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 }}>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{# Post-submit confirmation message embedded in the shared success modal. #}
|
|
||||||
<p>Successfully coded {{ count }} transactions.</p>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<div class="text-center py-4 text-gray-500">{{ message }}</div>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
|
|
||||||
<div id="payment-matches">{{ inner|safe }}</div>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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-)
|
||||||
|
|||||||
@@ -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-
|
||||||
|
|||||||
@@ -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)))
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)))
|
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 & 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 "<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()")))))
|
|
||||||
Reference in New Issue
Block a user