Compare commits
11 Commits
569e52d1c1
...
docs/ssr-r
| Author | SHA1 | Date | |
|---|---|---|---|
| d360316590 | |||
| 0e02c489e0 | |||
| 917b7f3857 | |||
| a8d8a8d111 | |||
| 360847fa58 | |||
| 55650c2dab | |||
| 19186097d5 | |||
| 1f6395382d | |||
| d52159637e | |||
| 3648597031 | |||
| 901d9eb508 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ sysco-poller/**/*.csv
|
||||
.tmp/**
|
||||
playwright-report/**
|
||||
test-results/**
|
||||
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
|
||||
/tmp/*
|
||||
!/tmp/.gitkeep
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Integreat Development Guide
|
||||
|
||||
## Temporary Files
|
||||
|
||||
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
### Build
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
# SSR Form & Wizard Simplification — Migration Plan
|
||||
|
||||
> **Status:** Planning / for execution by an agent or engineer.
|
||||
> **Owner:** Bryce
|
||||
> **Type:** Refactor (no user-facing behavior change; parity required).
|
||||
|
||||
This plan describes a series of low-risk migrations that make the server-side
|
||||
rendered (SSR) forms and wizards substantially simpler. It is self-contained:
|
||||
every concept needed to execute is stated here, illustrated with code snippets.
|
||||
The work is sequenced so each migration is small, reversible, and *teaches a
|
||||
skill* that makes the next migration cheaper.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||
the DOM in place. This removes the class of bugs around stale state, lost
|
||||
focus/caret, and out-of-band patching.
|
||||
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||
fakery forces. The target: a cursor always begins at the top level of what the
|
||||
form consumes and walks down naturally from there. (Because the whole form is
|
||||
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||
position.)
|
||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||
small data-driven engine instead of protocols + middleware stacking, and
|
||||
**store each step's data in the session** (combined only at the end) instead
|
||||
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
|
||||
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
||||
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
||||
first-class HTML rather than a mix of Clojure keywords and strings.
|
||||
5. **Capture the migration method in a skill** that is created after the first
|
||||
successful migration and extended by every migration thereafter.
|
||||
|
||||
Net effect target: large reduction in lines of code, route count, and branching
|
||||
complexity, with measurably more reuse across similar forms.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why — the current pain (rationale)
|
||||
|
||||
### 2.1 In-place DOM mutation is fragile
|
||||
Re-rendering only fragments and patching the rest (via morph or out-of-band
|
||||
swaps) means the server and the DOM can disagree. Keeping a focused input alive
|
||||
through a patch requires keying tricks and guards. Re-rendering the **whole
|
||||
form** and letting the typed value ride along in the form is simpler and
|
||||
correct, *provided the input the user is typing in is never inside the region
|
||||
being swapped*.
|
||||
|
||||
### 2.2 Faking cursor positions forces duplicate functions
|
||||
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||
nested render function can run against a fragment. That fakery is fragile and
|
||||
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||
faked cursor and one that takes plain params for the cases where the fake can't
|
||||
be set up.
|
||||
|
||||
```clojure
|
||||
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||
(defn account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||
...))
|
||||
|
||||
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||
...)
|
||||
```
|
||||
|
||||
**Target:** the cursor starts at the top of the form's data and walks down
|
||||
naturally; a row render either takes explicit row data or receives a cursor the
|
||||
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||
|
||||
### 2.3 Single-step forms wear wizard costumes
|
||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
||||
overhead.
|
||||
|
||||
### 2.4 Multi-step wizards round-trip and merge a snapshot
|
||||
The genuine multi-step wizards carry the whole accumulating form state as an EDN
|
||||
snapshot in hidden fields, then rebuild it each request by merging the posted
|
||||
pieces back into the snapshot. The serialization needs custom readers, the merge
|
||||
logic is error-prone, and the page payload grows with every step. The fix is to
|
||||
**store each step's data in the session under its own key and combine only at the
|
||||
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
|
||||
|
||||
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
|
||||
The same attribute is sometimes a keyword and sometimes a string in the same
|
||||
file, and event handlers must be strings while structural Alpine attrs are
|
||||
keywords. There is no rule a reader (or an LLM) can rely on:
|
||||
|
||||
```clojure
|
||||
;; Both of these appear in one component file today:
|
||||
:x-ref "input" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
:x-model "value.value"
|
||||
"x-model" "search"
|
||||
"@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
|
||||
<input x-ref="input" x-model="value.value"
|
||||
@keydown.down.prevent.stop="tippy?.show()" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Target state (the patterns, with snippets)
|
||||
|
||||
These four patterns are what every migration moves code *toward*. The skill
|
||||
(§5) holds the canonical, growing version of each.
|
||||
|
||||
### 3.1 Whole-form HTMX swap doctrine
|
||||
|
||||
Decide per interactive control, in this priority order:
|
||||
|
||||
1. **No request** when the field affects nothing else. Its value rides along in
|
||||
the form and is read on submit.
|
||||
```html
|
||||
<!-- a memo / free-text field that influences nothing -->
|
||||
<input name="memo" /> <!-- no hx-* at all -->
|
||||
```
|
||||
2. **Targeted swap of a single isolated cell** when a field's effect is purely
|
||||
local. Give the cell a stable id and keep it out of the typed input's subtree.
|
||||
```html
|
||||
<!-- selecting an account only changes the valid Location options -->
|
||||
<select name="accounts[0][account]"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#account-location-0"
|
||||
hx-select="#account-location-0"
|
||||
hx-swap="outerHTML" hx-trigger="changed">
|
||||
</select>
|
||||
<div id="account-location-0"> ...location options... </div>
|
||||
```
|
||||
3. **Whole-form swap** when the change touches interdependent state (vendor,
|
||||
add/remove row, mode toggle, $/% radio). The form's hidden state rides along,
|
||||
so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||
```html
|
||||
<form id="wizard-form"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
|
||||
...
|
||||
</form>
|
||||
```
|
||||
4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global
|
||||
flash/toast, a nav badge, a modal mounted at the document root. If you are
|
||||
tempted to OOB something *inside the same feature*, that is a signal to
|
||||
**restructure the DOM so the dependent element shares a common ancestor** with
|
||||
the trigger, and use an ordinary swap. Example: put running totals in a
|
||||
sibling `<tbody>` so an amount edit can swap totals without replacing the
|
||||
amount input:
|
||||
```clojure
|
||||
;; totals live in their own tbody, a sibling of the input rows
|
||||
(com/data-grid- {:rows ...
|
||||
:footer-tbody [:tbody {:id "account-totals"} ...]})
|
||||
|
||||
;; the amount input swaps ONLY the totals tbody (never itself)
|
||||
[:input {:name "accounts[0][amount]"
|
||||
:hx-post "/transaction/edit-form-changed"
|
||||
:hx-target "#account-totals" :hx-select "#account-totals"
|
||||
:hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
|
||||
```
|
||||
|
||||
**Focus invariant (must always hold):** the input the user is typing in is never
|
||||
inside the region its own request swaps.
|
||||
|
||||
**Alpine components must survive swaps.** Null-guard every reference that depends
|
||||
on Alpine/tippy being initialised, and key a component by its server-provided
|
||||
value so a server-driven change re-initialises it instead of preserving stale
|
||||
state:
|
||||
```clojure
|
||||
;; null-guard:
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||
;; key by current value so morph/replace re-inits on server change:
|
||||
(assoc attrs :key (str id "--" current-value))
|
||||
```
|
||||
|
||||
**Selector strategy for targeted swaps (a consideration, not a mandate).**
|
||||
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
|
||||
— a unique `id` on every swappable element — gets noisy in repeated structures
|
||||
(e.g. a table of financial accounts where choosing an account must swap *that
|
||||
row's* dropdown). When you reach those advanced cases, consider a more
|
||||
consistent scheme instead of hand-minting ids everywhere:
|
||||
|
||||
- **Semantic markup + data-attributes** to craft a fine-grained selector without
|
||||
per-element ids. For example, mark rows/cells with their identity and target
|
||||
by attribute:
|
||||
```html
|
||||
<tr data-row="account" data-index="0">
|
||||
<td data-cell="account">
|
||||
<select hx-post="/transaction/edit-form-changed"
|
||||
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-swap="outerHTML" hx-trigger="changed">…</select>
|
||||
</td>
|
||||
<td data-cell="location">…</td>
|
||||
</tr>
|
||||
```
|
||||
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
|
||||
cursor path is, so the server and the markup agree on the target by
|
||||
construction rather than by convention. A render fn at form-path
|
||||
`[:accounts 0 :location]` would compute its own stable selector (id or
|
||||
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
|
||||
|
||||
The aim is *consistency and predictability* of swap targets in repeated/nested
|
||||
structures — pick whichever keeps targets unambiguous and easy to generate. Note
|
||||
this in `reference/swap-doctrine.md` and let the first modal that hits nested
|
||||
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
|
||||
|
||||
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||
|
||||
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||
down to here**, never faked to start at this depth.
|
||||
|
||||
```clojure
|
||||
;; GOOD: pure, works everywhere, testable without setup
|
||||
(defn account-row [{:keys [account index client-id amount-mode]}]
|
||||
(com/data-grid-row
|
||||
(com/hidden {:name (str "accounts[" index "][db/id]")
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:name (str "accounts[" index "][account]")
|
||||
:client-id client-id}))
|
||||
...))
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||
(defn account-rows [accounts-cursor]
|
||||
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||
```
|
||||
|
||||
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||
|
||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||
|
||||
- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate
|
||||
+ save). State is plain form fields + an entity id. No snapshot, no server
|
||||
state, no protocol.
|
||||
```clojure
|
||||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||
```
|
||||
|
||||
- **Genuinely multi-step → data-driven engine with session-stored step state.**
|
||||
|
||||
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
|
||||
> round-trip a serialized blob of the whole form through the page. Each step's
|
||||
> validated (cleaned) data is written to a **storage backend (the user session
|
||||
> by default)** under that step's key, and the steps are combined only at the
|
||||
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
|
||||
> EDN snapshot + piecewise merging with per-step form state stored in the
|
||||
> session.** A step writes its own data under its own key; nothing is merged
|
||||
> into a snapshot and nothing about other steps rides through the form.
|
||||
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
|
||||
> (`SessionStorage`), and `get_all_cleaned_data()`
|
||||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||
|
||||
A wizard is *data*:
|
||||
```clojure
|
||||
(def vendor-wizard-config
|
||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||
:next (fn [data] :terms)}
|
||||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||||
:next (fn [data] :done)}]
|
||||
:init-fn (fn [req] {...})
|
||||
:submit-route "/admin/vendor/wizard/submit"
|
||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||
```
|
||||
with a tiny engine (no protocols) whose state lives **in the session**, keyed
|
||||
by a wizard instance id, with each step's data stored under its own step key —
|
||||
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
|
||||
merge-into-snapshot:
|
||||
```clojure
|
||||
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
|
||||
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
|
||||
(defn create-wizard! [session config]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||
|
||||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||
(defn forget [session id] (update session :wizards dissoc id))
|
||||
|
||||
(defn render-wizard [{:keys [wizard-id config session request]}]
|
||||
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
|
||||
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
||||
[:form#wizard-form {:hx-post (:submit-route config)
|
||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
||||
;; only a reference token rides in the form -- not the form's state
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
||||
|
||||
;; Handlers thread the (possibly updated) session back into the Ring response.
|
||||
(defn handle-step-submit [config {:keys [session] :as request}]
|
||||
(let [{:strs [wizard-id current-step]} (:form-params request)
|
||||
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
||||
data (select-keys (:form-params request) (map name (:fields step)))]
|
||||
(if-let [errors (mc/explain (:schema step) data)]
|
||||
(-> (render-wizard {:wizard-id wizard-id :config config :session session
|
||||
:request (assoc request :errors errors)})
|
||||
html-response)
|
||||
(let [session' (put-step session wizard-id (keyword current-step) data)
|
||||
nxt ((:next step) data)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
|
||||
(assoc :session (forget session' wizard-id)))
|
||||
(let [session'' (set-step session' wizard-id nxt)]
|
||||
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
|
||||
:session session'' :request request}))
|
||||
(assoc :session session''))))))))
|
||||
```
|
||||
Two routes per wizard: open (`partial open-wizard config`) and submit
|
||||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
|
||||
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
|
||||
discarded on completion (`forget`). See Open decision 1 for the storage-backend
|
||||
choice (Ring session store vs. a durable store for long-lived wizards).
|
||||
|
||||
### 3.4 Selmer templates
|
||||
|
||||
Interactive components render from Selmer templates with plain-HTML attributes.
|
||||
Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a
|
||||
Selmer template embed Hiccup output (and vice versa) during the transition.
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; render helper + interop bridge
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Principles
|
||||
|
||||
1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap
|
||||
doctrine live alongside the old code. Migrate one modal at a time behind its
|
||||
own route. Old machinery is deleted only when its last caller is gone.
|
||||
2. **Simplest first.** Each migration is small and reversible (one commit).
|
||||
Start with the already-proven modal, then the smallest fresh ones, and leave
|
||||
the largest/most complex for last — by which point the skill is mature.
|
||||
3. **Skill-driven and self-reinforcing.** After the first successful migration,
|
||||
distil the method into a skill (§5). Every subsequent migration *reads* the
|
||||
skill first and *extends* it last.
|
||||
4. **Quality must measurably improve.** Each migration records a scorecard (§6);
|
||||
no metric may regress for the touched modal.
|
||||
5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e
|
||||
suite must stay green after every migration.
|
||||
|
||||
---
|
||||
|
||||
## 5. The skill: `ssr-form-migration`
|
||||
|
||||
**When it is created:** in **Phase 1**, immediately after — and distilled from —
|
||||
the first successful modal migration (the transaction-edit modal, whose
|
||||
whole-form swap implementation already exists and serves as the reference). The
|
||||
skill is *not* written speculatively; it encodes a method that already worked.
|
||||
|
||||
**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project
|
||||
convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
.claude/skills/ssr-form-migration/
|
||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||
reference/
|
||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
|
||||
# target-selector strategy (semantic/data-attr/form-path->id)
|
||||
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||
gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
|
||||
test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
|
||||
scorecard.md # the §6 heuristics + a running table of every migration's numbers
|
||||
```
|
||||
|
||||
**Growth contract — the last task of every migration:**
|
||||
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||
- Hit a surprise? → one entry in `gotchas.md`.
|
||||
- Found a test pattern? → `test-recipes.md`.
|
||||
- Playbook step missing/wrong? → fix `SKILL.md`.
|
||||
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||
|
||||
**Success signal:** each migration should reuse more cookbook entries and start
|
||||
from a better scorecard baseline than the previous one. If migration N+1 is not
|
||||
easier than N, the skill-update step is being skipped — treat that as a bug.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality scorecard (the ratchet)
|
||||
|
||||
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each
|
||||
migration in the commit message and `scorecard.md`. **No metric may regress for
|
||||
the touched modal.**
|
||||
|
||||
| # | Heuristic | Measure | Target |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
||||
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
|
||||
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
|
||||
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
|
||||
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
|
||||
|
||||
These are directional evidence, not targets to game. Pair them with the e2e
|
||||
parity gate (§7) so "simpler" can never mean "broken."
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
Consistent with the project's `testing-conventions` skill (test user-observable
|
||||
behavior; assert DB state directly; don't test the means).
|
||||
|
||||
1. **Characterization e2e first.** Before changing a modal, write/confirm a
|
||||
Playwright spec capturing its current behavior — focus/caret survival across
|
||||
swaps, the field round-trip, validation errors, and the actual save. This
|
||||
spec is the parity contract the refactor must keep green.
|
||||
2. **Pure-function checks via REPL.** Once render fns are pure, exercise the
|
||||
data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned
|
||||
data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`)
|
||||
— this style survives the Selmer switch. Avoid brittle structural assertions.
|
||||
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by
|
||||
querying the DB, not by asserting on markup.
|
||||
|
||||
**Regression gate:** the full e2e suite must stay green after every migration.
|
||||
Record the current pass/fail baseline in `test-recipes.md` at the first
|
||||
migration and never drop below it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Per-migration playbook (the repeatable loop)
|
||||
|
||||
This is the canonical loop each modal phase follows; it lives in `SKILL.md`.
|
||||
Modal phases below list only what is *specific* to that modal plus this loop.
|
||||
|
||||
1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas.
|
||||
2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step →
|
||||
wizard (engine + server state). When in doubt, it's a form.
|
||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||
(heuristics 5, 8).
|
||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||
only for disjoint regions (heuristic 7).
|
||||
8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6).
|
||||
9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check
|
||||
pure fns. Re-measure scorecard — no regressions.
|
||||
10. [ ] **Commit** one reversible feature commit; message includes the scorecard
|
||||
delta and reused/new cookbook entries.
|
||||
11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard /
|
||||
SKILL.md). *Not optional.*
|
||||
|
||||
---
|
||||
|
||||
## 9. Phases & tasks
|
||||
|
||||
> Migration target inventory (verify line counts at execution time):
|
||||
|
||||
| Modal | File | Steps | Target | Phase |
|
||||
|-------|------|-------|--------|-------|
|
||||
| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) |
|
||||
| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 |
|
||||
| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 |
|
||||
| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 |
|
||||
| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 |
|
||||
| Invoice Pay | `invoices.clj` | 2 | wizard | 7 |
|
||||
| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 |
|
||||
| Vendor | `admin/vendors.clj` | 5 | wizard | 9 |
|
||||
| Client | `admin/clients.clj` | 7 | wizard | 10 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Distil the skill (no app code changes)
|
||||
|
||||
**Rationale:** the transaction-edit modal has already been migrated to the
|
||||
whole-form swap approach successfully. Capture that working method as a skill
|
||||
*now*, so every later migration is cheaper and consistent. (If the reference
|
||||
implementation is not yet on the working branch, merge it first — that is an
|
||||
acceptable prerequisite.)
|
||||
|
||||
- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8).
|
||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||
swaps as worked examples.
|
||||
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||
Phase 2."
|
||||
- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved
|
||||
(e.g. the hardened typeahead, the totals-in-sibling-`<tbody>` pattern).
|
||||
- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value).
|
||||
- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**.
|
||||
- [ ] Create `scorecard.md` with the §6 table and an empty results table.
|
||||
- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and
|
||||
understand the whole method without this plan.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Trial the skill on Transaction Edit (first test subject)
|
||||
|
||||
**Rationale:** validate the freshly written skill against the one modal whose
|
||||
"correct" outcome we already know. This is also where Selmer + pure functions
|
||||
are completed for this modal and the Selmer conventions get written from a real,
|
||||
verified example. Target type: **plain form** (single step with a mode toggle —
|
||||
the toggle is just a `GET` with a `?mode=` query param that re-renders the form).
|
||||
|
||||
**Foundation (do once, here):**
|
||||
- [ ] Add the `selmer` dependency to `project.clj`.
|
||||
- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge**
|
||||
(Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
|
||||
- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout,
|
||||
and a Hiccup component renders inside a Selmer template.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps
|
||||
focus; selecting an account updates only its Location options; changing vendor
|
||||
/ adding / removing a row / toggling mode / toggling $-vs-% re-renders the
|
||||
whole form correctly; amount edits update totals without losing the amount
|
||||
caret; save round-trips.
|
||||
- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`,
|
||||
`account-row`, `account-totals` (remove any `*-no-cursor*` duplicates).
|
||||
- [ ] Convert those render fns to Selmer templates; record each as a cookbook
|
||||
entry; finalize `selmer-conventions.md`.
|
||||
- [ ] Verify the swaps match the doctrine (whole-form for structural changes,
|
||||
targeted cell for account→location, sibling-`<tbody>` for totals, no request
|
||||
for memo); confirm `grep -c hx-swap-oob` is 0.
|
||||
- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST
|
||||
/transaction/edit`, plus the single `edit-form-changed` re-render endpoint.
|
||||
- [ ] Verify (modal e2e + full suite green; DB save asserted).
|
||||
- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the
|
||||
trial revealed; append the scorecard row (this is the baseline others beat).
|
||||
- [ ] **Exit criteria:** skill-driven migration reproduces the known-good
|
||||
behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Transaction Bulk Code (plain form)
|
||||
|
||||
**Rationale:** the smallest *fresh* modal — first real test of "read the skill,
|
||||
apply it cold." Single-step form currently wearing a wizard costume.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; delete the wizard protocol/record and snapshot.
|
||||
- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input.
|
||||
- [ ] Search params preserved as plain hidden fields (no EDN snapshot).
|
||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||
faked-cursor count all down vs. baseline.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Sales Summary Edit (plain form)
|
||||
|
||||
**Rationale:** another single-step form; reinforces the cold-apply loop.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`.
|
||||
- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries.
|
||||
- [ ] Collapse 3 wizard routes → 2.
|
||||
- [ ] Verify edit saves (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Invoice Bulk Edit (plain form with rows + totals)
|
||||
|
||||
**Rationale:** first single-step form with dynamic account rows and live totals
|
||||
— exercises the add-row endpoint and the totals-in-sibling-`<tbody>` swap
|
||||
(instead of OOB).
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals`
|
||||
cookbook entries from Phase 2.
|
||||
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
||||
sibling-`<tbody>` swap, **not** OOB.
|
||||
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
|
||||
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
|
||||
helper, rather than hand-minted ids per element. Record the chosen convention
|
||||
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
|
||||
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
||||
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
|
||||
confirmed reusable across two modals now.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
||||
|
||||
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
||||
right place to introduce the data-driven engine (§3.3) and **session-stored
|
||||
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
|
||||
merge.
|
||||
|
||||
**Engine (do once, here):**
|
||||
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
|
||||
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
|
||||
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
|
||||
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
|
||||
and concurrent wizards don't collide. Each fn returns the updated session for
|
||||
the handler to thread into the Ring response. Test the lifecycle via REPL.
|
||||
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
||||
`open-wizard`) — engine threads session through and only `wizard-id` rides in
|
||||
the form. Test render + step navigation + that no snapshot is emitted.
|
||||
- [ ] Document the engine usage and the formtools inspiration in
|
||||
`reference/form-vs-wizard.md`.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
||||
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
|
||||
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
|
||||
- [ ] Collapse routes → 2 (open, submit).
|
||||
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Invoice Pay (2-step wizard)
|
||||
|
||||
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
|
||||
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
|
||||
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
|
||||
`invoice-by-id` lookup); branch `:next` on payment method.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify each payment method path (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — New Invoice (3-step wizard)
|
||||
|
||||
**Rationale:** a true 3-step wizard with a conditional accounts step — the
|
||||
reference multi-step shape.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
|
||||
`render-submit-step`; reuse the expense-account row cookbook entry.
|
||||
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
|
||||
when not customizing.
|
||||
- [ ] `:init-fn` sets defaults (e.g. date = now).
|
||||
- [ ] Add-row for expense accounts via the sibling-`<tbody>` totals pattern.
|
||||
- [ ] Collapse routes → 2 (+1 add-row).
|
||||
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — Vendor (5-step wizard)
|
||||
|
||||
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
|
||||
`render-account-step`, `render-address-step`, `render-legal-step`.
|
||||
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
|
||||
(empty vs. loaded entity).
|
||||
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Client (7-step wizard) — largest, last
|
||||
|
||||
**Rationale:** the biggest, most complex modal (nested bank accounts, location
|
||||
matches, emails, contact methods). Deliberately last, when the skill is richest.
|
||||
|
||||
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
|
||||
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
|
||||
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
|
||||
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
|
||||
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
|
||||
drop `fc/with-field-default` nesting.
|
||||
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
|
||||
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
|
||||
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
|
||||
suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 11 — Cleanup
|
||||
|
||||
**Rationale:** remove the now-dead old machinery.
|
||||
|
||||
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
|
||||
remains; remove any v1→v2 shim.
|
||||
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
|
||||
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
|
||||
Hiccup, now that the skill makes it cheap.
|
||||
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
|
||||
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
|
||||
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
||||
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
||||
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
||||
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
|
||||
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
|
||||
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
|
||||
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions
|
||||
|
||||
1. **Wizard state storage** — store multi-step state in the **Ring session**
|
||||
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
|
||||
plain forms? Confirm the session backend in use (in-memory vs. durable) is
|
||||
acceptable for in-flight wizard lifetime, or pick a durable store for
|
||||
long-lived flows. *(recommended: session storage, scoped to multi-step
|
||||
wizards only)*
|
||||
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
||||
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
||||
Phase 11)*
|
||||
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
|
||||
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
|
||||
project default. *(recommended: yes)*
|
||||
4. **First step** — start by distilling the skill (Phase 1) with the reference
|
||||
implementation merged as a prerequisite, rather than treating the merge
|
||||
itself as step one. *(recommended: yes)*
|
||||
File diff suppressed because one or more lines are too long
@@ -333,7 +333,8 @@
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
||||
|
||||
(mark-all-dirty 5)
|
||||
(mark-all-dirty 14)
|
||||
|
||||
(delete-all)
|
||||
|
||||
(sales-summaries-v2)
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[datomic.api :as dc]
|
||||
[hiccup2.core :as hiccup]))
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(defn hourly-changes []
|
||||
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
|
||||
@@ -56,34 +55,68 @@
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Growth in clients"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "client-chart"
|
||||
:data-chart (hx/json {:labels ["2 years ago" "1 year ago" "today"],
|
||||
:series [(for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Bar('#client-chart', JSON.parse(document.getElementById('client-chart').getAttribute('data-chart')))")]]]])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels ["2 years ago" "1 year ago" "today"]
|
||||
:data (for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Clients',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])
|
||||
|
||||
(com/content-card {:class "w-1/2"}
|
||||
[:div {:class "flex flex-col px-4 py-3 space-y-3"}
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Changes by hour"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "changes"
|
||||
:data-chart (hx/json {:labels (for [n (range -24 0)]
|
||||
(format "%d" n)),
|
||||
:series [(hourly-changes)]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Line('#changes', JSON.parse(document.getElementById('changes').getAttribute('data-chart')))")]]]])])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels (for [n (range -24 0)]
|
||||
(format "%d" n))
|
||||
:data (hourly-changes)})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Changes',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])])
|
||||
"Admin"))
|
||||
|
||||
(def key->handler
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
[:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))})
|
||||
[:div {:class (:max-w params "max-w-screen-2xl")}
|
||||
(into
|
||||
[:div {:class "relative overflow-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
[:div {:class "relative overflow-auto shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
children)]])
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
[auto-ap.routes.transactions :as transaction-routes]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.date-range :as dr]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.grid-page-helper :as helper]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [clj-date-schema entity-id html-response ref->enum-schema
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
(defn exact-match-id* [request]
|
||||
(if (nat-int? (:exact-match-id (:query-params request)))
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"}
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
|
||||
(com/hidden {:name "exact-match-id"
|
||||
"x-model" "exact_match"})
|
||||
(com/pill {:color :primary}
|
||||
@@ -46,13 +46,14 @@
|
||||
[:div {:hx-trigger "clientSelected from:body"
|
||||
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
:hx-swap "outerHTML"
|
||||
:class "filter-trigger"}
|
||||
(when (:client request)
|
||||
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
|
||||
(:db/id (:bank-account (:query-params request))))]
|
||||
(com/field {:label "Bank Account"}
|
||||
(com/radio-card {:size :small
|
||||
:name "bank-account"
|
||||
(com/radio-card {:size :small
|
||||
:name "bank-account"
|
||||
:value (or (when bank-account-belongs-to-client?
|
||||
(:db/id (:bank-account (:query-params request))))
|
||||
"")
|
||||
@@ -60,90 +61,96 @@
|
||||
(into [{:value ""
|
||||
:content "All"}]
|
||||
(for [ba (:client/bank-accounts (:client request))]
|
||||
{:value (:db/id ba)
|
||||
{:value (:db/id ba)
|
||||
:content (:bank-account/name ba)}))}))))])
|
||||
|
||||
(defn bank-account-filter [request]
|
||||
(html-response (bank-account-filter* request)))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#ledger-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
[:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
(com/hidden {:name "status"
|
||||
:value (some-> (:status (:query-params request)) name)})
|
||||
[:fieldset.space-y-6
|
||||
(com/field {:label "Vendor"}
|
||||
(com/typeahead {:name "vendor"
|
||||
:id "vendor"
|
||||
(com/typeahead {:name "vendor"
|
||||
:id "vendor"
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value (:vendor (:query-params request))
|
||||
:value (:vendor (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn :vendor/name}))
|
||||
:content-fn :vendor/name
|
||||
:class "filter-trigger"}))
|
||||
(com/field {:label "Account"}
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
:url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
:value (:account (:query-params request))
|
||||
:value (:account (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
||||
(:db/id (:client request))))}))
|
||||
(:db/id (:client request))))
|
||||
:class "filter-trigger"}))
|
||||
|
||||
(bank-account-filter* request)
|
||||
|
||||
(date-range-field* request)
|
||||
(dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true})
|
||||
(com/field {:label "Invoice #"}
|
||||
(com/text-input {:name "invoice-number"
|
||||
:id "invoice-number"
|
||||
:class "hot-filter"
|
||||
:value (:invoice-number (:query-params request))
|
||||
(com/text-input {:name "invoice-number"
|
||||
:id "invoice-number"
|
||||
:class "hot-filter"
|
||||
:value (:invoice-number (:query-params request))
|
||||
:placeholder "e.g., ABC-456"
|
||||
:size :small}))
|
||||
:size :small}))
|
||||
|
||||
(com/field {:label "Account Code"}
|
||||
[:div.flex.space-x-4.items-baseline
|
||||
(com/int-input {:name "numeric-code-gte"
|
||||
:id "numeric-code-gte"
|
||||
(com/int-input {:name "numeric-code-gte"
|
||||
:id "numeric-code-gte"
|
||||
:hx-preserve "true"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-gte (:query-params request))
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-gte (:query-params request))
|
||||
:placeholder "40000"
|
||||
:size :small})
|
||||
:size :small})
|
||||
[:div.align-baseline
|
||||
"to"]
|
||||
(com/int-input {:name "numeric-code-lte"
|
||||
(com/int-input {:name "numeric-code-lte"
|
||||
:hx-preserve "true"
|
||||
:id "numeric-code-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-lte (:query-params request))
|
||||
:id "numeric-code-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-lte (:query-params request))
|
||||
:placeholder "50000"
|
||||
:size :small})])
|
||||
:size :small})])
|
||||
|
||||
(com/field {:label "Amount"}
|
||||
[:div.flex.space-x-4.items-baseline
|
||||
(com/money-input {:name "amount-gte"
|
||||
:id "amount-gte"
|
||||
(com/money-input {:name "amount-gte"
|
||||
:id "amount-gte"
|
||||
:hx-preserve "true"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-gte (:query-params request))
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-gte (:query-params request))
|
||||
:placeholder "0.01"
|
||||
:size :small})
|
||||
:size :small})
|
||||
[:div.align-baseline
|
||||
"to"]
|
||||
(com/money-input {:name "amount-lte"
|
||||
(com/money-input {:name "amount-lte"
|
||||
:hx-preserve "true"
|
||||
:id "amount-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-lte (:query-params request))
|
||||
:id "amount-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-lte (:query-params request))
|
||||
:placeholder "9999.34"
|
||||
:size :small})])
|
||||
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
||||
:size :small})])
|
||||
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
||||
(com/hidden {:name "only-unbalanced"
|
||||
":value" "onlyUnbalanced ? 'on' : ''"})
|
||||
(com/checkbox {:value (:only-unbalanced (:query-params request))
|
||||
:class "filter-trigger"
|
||||
:x-model "onlyUnbalanced"}
|
||||
"Show unbalanced")]
|
||||
(exact-match-id* request)]])
|
||||
@@ -184,12 +191,12 @@
|
||||
args query-params
|
||||
query
|
||||
(if (:exact-match-id args)
|
||||
{:query {:find '[?e]
|
||||
:in '[$ ?e [?c ...]]
|
||||
{:query {:find '[?e]
|
||||
:in '[$ ?e [?c ...]]
|
||||
:where '[[?e :journal-entry/client ?c]]}
|
||||
:args [db
|
||||
(:exact-match-id args)
|
||||
valid-clients]}
|
||||
:args [db
|
||||
(:exact-match-id args)
|
||||
valid-clients]}
|
||||
(cond-> {:query {:find []
|
||||
:in ['$ '[?clients ?start ?end]]
|
||||
:where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
|
||||
@@ -202,28 +209,28 @@
|
||||
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
|
||||
|
||||
(seq (:external-id-like args))
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
:where ['[?e :journal-entry/external-id ?external-id]
|
||||
'[(.contains ^String ?external-id ?external-id-like)]]}
|
||||
:args [(:external-id-like args)]})
|
||||
:args [(:external-id-like args)]})
|
||||
|
||||
(seq (:source args))
|
||||
(merge-query {:query {:in ['?source]
|
||||
(merge-query {:query {:in ['?source]
|
||||
:where ['[?e :journal-entry/source ?source]]}
|
||||
:args [(:source args)]})
|
||||
:args [(:source args)]})
|
||||
(:external? route-params)
|
||||
(merge-query {:query {:where ['[?e :journal-entry/external-id]]}})
|
||||
|
||||
(:vendor args)
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
:where ['[?e :journal-entry/vendor ?vendor-id]]}
|
||||
:args [(:db/id (:vendor args))]})
|
||||
:args [(:db/id (:vendor args))]})
|
||||
|
||||
(:invoice-number args)
|
||||
(merge-query {:query {:in ['?invoice-number]
|
||||
(merge-query {:query {:in ['?invoice-number]
|
||||
:where ['[?e :journal-entry/original-entity ?oe]
|
||||
'[?oe :invoice/invoice-number ?invoice-number]]}
|
||||
:args [(:invoice-number args)]})
|
||||
:args [(:invoice-number args)]})
|
||||
|
||||
(or (:numeric-code-lte args)
|
||||
(:numeric-code-gte args)
|
||||
@@ -235,77 +242,77 @@
|
||||
|
||||
(or (:numeric-code-gte args)
|
||||
(:numeric-code-lte args))
|
||||
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
||||
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
||||
:where ['[?li :journal-entry-line/account ?a]
|
||||
'(or-join [?a ?c]
|
||||
[?a :account/numeric-code ?c]
|
||||
[?a :bank-account/numeric-code ?c])
|
||||
'[(>= ?c ?from-numeric-code)]
|
||||
'[(<= ?c ?to-numeric-code)]]}
|
||||
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
||||
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
||||
|
||||
(seq (:numeric-code args))
|
||||
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
||||
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
||||
:where ['[?li :journal-entry-line/account ?a]
|
||||
'(or-join [?a ?c]
|
||||
[?a :account/numeric-code ?c]
|
||||
[?a :bank-account/numeric-code ?c])
|
||||
'[(>= ?c ?from-numeric-code)]
|
||||
'[(<= ?c ?to-numeric-code)]]}
|
||||
:args [(map (juxt :from :to) (:numeric-code args))]})
|
||||
:args [(map (juxt :from :to) (:numeric-code args))]})
|
||||
(seq (:account args))
|
||||
(merge-query {:query {:in ['?a3]
|
||||
(merge-query {:query {:in ['?a3]
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [(:db/id (:account args))]})
|
||||
:args [(:db/id (:account args))]})
|
||||
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(>= ?a ?amount-gte)]]}
|
||||
:args [(:amount-gte args)]})
|
||||
:args [(:amount-gte args)]})
|
||||
|
||||
(:amount-lte args)
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(<= ?a ?amount-lte)]]}
|
||||
:args [(:amount-lte args)]})
|
||||
:args [(:amount-lte args)]})
|
||||
|
||||
(:db/id (:bank-account args))
|
||||
(merge-query {:query {:in ['?a]
|
||||
(merge-query {:query {:in ['?a]
|
||||
:where ['[?li :journal-entry-line/account ?a]]}
|
||||
:args [(:db/id (:bank-account args))]})
|
||||
:args [(:db/id (:bank-account args))]})
|
||||
|
||||
(:account-id args)
|
||||
(merge-query {:query {:in ['?a2]
|
||||
(merge-query {:query {:in ['?a2]
|
||||
:where ['[?e :journal-entry/line-items ?li2]
|
||||
'[?li2 :journal-entry-line/account ?a2]]}
|
||||
:args [(:account-id args)]})
|
||||
:args [(:account-id args)]})
|
||||
|
||||
(not-empty (:location args))
|
||||
(merge-query {:query {:in ['?location]
|
||||
(merge-query {:query {:in ['?location]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:location args)]})
|
||||
:args [(:location args)]})
|
||||
|
||||
(not-empty (:locations args))
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:locations args)]})
|
||||
:args [(:locations args)]})
|
||||
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"date" ['[?e :journal-entry/date ?sort-date]]
|
||||
"vendor" '[(or-join [?e ?sort-vendor]
|
||||
(and
|
||||
[?e :journal-entry/vendor ?v]
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :journal-entry/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"date" ['[?e :journal-entry/date ?sort-date]]
|
||||
"vendor" '[(or-join [?e ?sort-vendor]
|
||||
(and
|
||||
[?e :journal-entry/vendor ?v]
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :journal-entry/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
||||
"external-id" ['[?e :journal-entry/external-id ?sort-external-id]]
|
||||
"source" '[(or-join [?e ?sort-source]
|
||||
[?e :journal-entry/source ?sort-source]
|
||||
(and [(missing? $ ?e :journal-entry/source)]
|
||||
[(ground "") ?sort-source]))]}
|
||||
"source" '[(or-join [?e ?sort-source]
|
||||
[?e :journal-entry/source ?sort-source]
|
||||
(and [(missing? $ ?e :journal-entry/source)]
|
||||
[(ground "") ?sort-source]))]}
|
||||
args)
|
||||
|
||||
true
|
||||
@@ -334,11 +341,11 @@
|
||||
:journal-entry/external-id
|
||||
:db/id
|
||||
[:journal-entry/date :xform clj-time.coerce/from-date]
|
||||
{:journal-entry/vendor [:vendor/name :db/id]
|
||||
{:journal-entry/vendor [:vendor/name :db/id]
|
||||
:journal-entry/original-entity [:invoice/invoice-number
|
||||
:invoice/source-url
|
||||
:transaction/description-original :db/id]
|
||||
:journal-entry/client [:client/name :client/code :db/id]
|
||||
:journal-entry/client [:client/name :client/code :db/id]
|
||||
:journal-entry/line-items [:journal-entry-line/debit
|
||||
:journal-entry-line/location
|
||||
:journal-entry-line/running-balance
|
||||
@@ -362,8 +369,8 @@
|
||||
(defn sum-outstanding [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/outstanding-balance ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
@@ -375,8 +382,8 @@
|
||||
(defn sum-total-amount [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/total ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
@@ -386,7 +393,7 @@
|
||||
0.0)))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count
|
||||
all-ids :all-ids} (fetch-ids db request)]
|
||||
|
||||
@@ -410,12 +417,12 @@
|
||||
(if account-name
|
||||
[:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel)
|
||||
(format "$%,.2f"))))}
|
||||
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
||||
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
||||
(:journal-entry-line/location jel) ": "
|
||||
(or (:account/numeric-code account) (:bank-account/numeric-code account))
|
||||
" - " account-name]]
|
||||
[:div.text-left (com/pill {:color :yellow} "Unassigned")])
|
||||
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
||||
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
||||
|
||||
(when-not (= 1 (count lines))
|
||||
[:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines
|
||||
@@ -443,9 +450,9 @@
|
||||
[:to nat-int?]]]]]
|
||||
[:numeric-code-gte {:optional true} [:maybe nat-int?]]
|
||||
[:numeric-code-lte {:optional true} [:maybe nat-int?]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
||||
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
||||
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
||||
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]]
|
||||
@@ -459,17 +466,20 @@
|
||||
[:maybe clj-date-schema]]]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
:check-boxes? true
|
||||
:check-box-warning? (fn [e]
|
||||
(some? (:invoice/scheduled-payment e)))
|
||||
:page-specific-nav filters
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
:fetch-page fetch-page
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true}) [1 :hx-swap-oob] true)
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
:query-schema query-schema
|
||||
:action-buttons (fn [request]
|
||||
[(when-not (:external? (:route-params request)) (com/button {:color :primary
|
||||
@@ -485,7 +495,7 @@
|
||||
:hx-confirm "Are you sure you want to void this invoice?"}
|
||||
svg/trash))
|
||||
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
@@ -497,14 +507,14 @@
|
||||
:db/id (:db/id entity))}
|
||||
svg/undo))])
|
||||
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]]
|
||||
:title (fn [r]
|
||||
(str
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
"Register"))
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]]
|
||||
:title (fn [r]
|
||||
(str
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
"Register"))
|
||||
:entity-name "register"
|
||||
:route ::route/table
|
||||
:route ::route/table
|
||||
:csv-route ::route/csv
|
||||
:break-table (fn [request entity]
|
||||
(cond
|
||||
@@ -521,102 +531,102 @@
|
||||
(for [je journal-entries
|
||||
jel (:journal-entry/line-items je)]
|
||||
(merge jel je)))
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(and (= (count (:clients args)) 1)
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(and (= (count (:clients args)) 1)
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
||||
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
||||
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
(-> e :journal-entry/alternate-description)))}
|
||||
{:key "source"
|
||||
:name "Source"
|
||||
:sort-key "source"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render :journal-entry/source
|
||||
:render-csv :journal-entry/source}
|
||||
{:key "external-id"
|
||||
:name "External Id"
|
||||
:sort-key "external-id"
|
||||
:class "max-w-[12rem]"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
||||
:render-csv :journal-entry/external-id}
|
||||
{:key "date"
|
||||
:sort-key "date"
|
||||
:name "Date"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "amount"
|
||||
:sort-key "amount"
|
||||
:name "Amount"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
||||
(-> % :journal-entry-line/account :bank-account/name))
|
||||
:render-for #{:csv}}
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/debit)
|
||||
:render-csv :journal-entry-line/debit}
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
||||
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
(-> e :journal-entry/alternate-description)))}
|
||||
{:key "source"
|
||||
:name "Source"
|
||||
:sort-key "source"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render :journal-entry/source
|
||||
:render-csv :journal-entry/source}
|
||||
{:key "external-id"
|
||||
:name "External Id"
|
||||
:sort-key "external-id"
|
||||
:class "max-w-[12rem]"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
||||
:render-csv :journal-entry/external-id}
|
||||
{:key "date"
|
||||
:sort-key "date"
|
||||
:name "Date"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "amount"
|
||||
:sort-key "amount"
|
||||
:name "Amount"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
||||
(-> % :journal-entry-line/account :bank-account/name))
|
||||
:render-for #{:csv}}
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/debit)
|
||||
:render-csv :journal-entry-line/debit}
|
||||
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/credit)
|
||||
:render-csv :journal-entry-line/credit}
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/credit)
|
||||
:render-csv :journal-entry-line/credit}
|
||||
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(cond-> []
|
||||
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::invoice-route/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
||||
(-> i :journal-entry/original-entity :invoice/source-url)
|
||||
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
||||
:color :secondary
|
||||
:content (str "File")}
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(cond-> []
|
||||
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::invoice-route/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
||||
(-> i :journal-entry/original-entity :invoice/source-url)
|
||||
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
||||
:color :secondary
|
||||
:content (str "File")}
|
||||
|
||||
(-> i :journal-entry/original-entity :transaction/description-original)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
||||
(-> i :journal-entry/memo)
|
||||
(conj {:color :secondary
|
||||
:content (str "Memo: " (:journal-entry/memo i))}))))
|
||||
:render-for #{:html}}]}))
|
||||
(-> i :journal-entry/original-entity :transaction/description-original)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
||||
(-> i :journal-entry/memo)
|
||||
(conj {:color :secondary
|
||||
:content (str "Memo: " (:journal-entry/memo i))}))))
|
||||
:render-for #{:html}}]}))
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
@@ -23,8 +23,6 @@
|
||||
[:title (str "Integreat | " page-name)]
|
||||
[:link {:href "/css/font.min.css", :rel "stylesheet"}]
|
||||
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
|
||||
[:link {:rel "stylesheet" :href "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.css"}]
|
||||
[:script {:src "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.js"}]
|
||||
[:link {:rel "stylesheet", :href "/output.css"}]
|
||||
[:script {:src "https://cdn.plaid.com/link/v2/stable/link-initialize.js"}]
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
|
||||
|
||||
0
tmp/.gitkeep
Normal file
0
tmp/.gitkeep
Normal file
Reference in New Issue
Block a user