27 Commits

Author SHA1 Message Date
5452b8b779 critique of wizard design. 2026-06-01 09:40:49 -07:00
5c2cf8a631 agent changes 2026-05-30 00:08:27 -07:00
b8a0e9c3dc merged. 2026-05-29 17:32:33 -07:00
9659164fdc instructions 2026-05-29 11:07:44 -07:00
8f0a474fa8 resources 2026-05-29 10:55:34 -07:00
6814cf1b15 better login page 2026-05-29 10:55:14 -07:00
3441ae63b4 fix: normalize account ref map to db/id in simple mode rendering
When a transaction is pre-coded, the snapshot stores :transaction-account/account
as a Datomic ref map {:db/id N} rather than a bare integer. simple-mode-fields*
and the simpleAccountId Alpine initializer both need the integer id, not the map,
to correctly populate the account typeahead value and the x-hx-val binding.
2026-05-28 00:10:15 -07:00
79ddda624a fix: change toggle-mode links from hx-get to hx-post so form data is submitted 2026-05-27 23:53:43 -07:00
cbb9bc750d fix: prefer step-params over snapshot for row-count and existing-row 2026-05-27 23:49:59 -07:00
a7ac7eae35 refactor: remove dead code and redundant assertions in simple/advanced mode tests 2026-05-27 23:44:18 -07:00
c6699dd05a test: complete AC17 fields-present assertions 2026-05-27 23:39:35 -07:00
1be83a01f5 test: add missing AC tests and fix AC numbering 2026-05-27 23:36:08 -07:00
ebd91f1911 test: strengthen assertions and add AC6 save round-trip test 2026-05-27 23:25:47 -07:00
c9a587a8c5 test: add tests for simple/advanced mode in transaction edit modal 2026-05-27 23:17:10 -07:00
9997d60de1 fix: handle absent :transaction/accounts in simple-mode-fields* (ValCursor crash) 2026-05-27 23:07:52 -07:00
06fb0ea067 feat: update edit-vendor-changed-handler to support simple/advanced mode 2026-05-27 22:03:13 -07:00
9a7d0b8b18 feat: add edit-wizard-toggle-mode-handler 2026-05-27 21:59:33 -07:00
70a3db9a64 fix: repair vendor-changed swap target and remove unused declare 2026-05-27 21:57:11 -07:00
4e22fb1d82 feat: extract manual-coding-section* with simple/advanced mode selection 2026-05-27 21:52:09 -07:00
a88dcf4122 fix: safe cursor indexing in simple-mode-fields* 2026-05-27 21:44:54 -07:00
00b5303c28 feat: add simple-mode-fields* for transaction edit modal 2026-05-27 21:40:04 -07:00
ab1a2c3368 feat: add edit-wizard-toggle-mode route 2026-05-27 21:35:21 -07:00
724b6d82f5 docs: add implementation plan for transaction edit simple/advanced mode 2026-05-27 21:33:17 -07:00
6500c44909 docs: fix spec inconsistencies from self-review 2026-05-27 21:10:20 -07:00
2e4152e3fc docs: add spec for transaction edit modal simple/advanced mode 2026-05-27 21:09:37 -07:00
6ce6a6e0c7 restored default. 2026-05-27 14:14:12 -07:00
17eebe5628 Add http-port override, login UI improvements, and dev tooling 2026-05-27 14:13:43 -07:00
34 changed files with 4624 additions and 373 deletions

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@@ -0,0 +1 @@
../../.agents/skills/agent-browser

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ pom.xml.asc
*.class
/.lein-*
/.nrepl-port
nrepl-port
.http-port
resources/public/js/compiled
*.log
examples/

8
.opencode/opencode.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": {
"src/*": "deny"
}
}
}

View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.15.10"
"@opencode-ai/plugin": "1.15.12"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
@@ -87,19 +87,19 @@
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
"integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.12.tgz",
"integrity": "sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.15.10",
"@opencode-ai/sdk": "1.15.12",
"effect": "4.0.0-beta.66",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15"
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -114,9 +114,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
"integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz",
"integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"

View File

@@ -34,6 +34,14 @@ INTEGREAT_JOB="" lein run # Default: port 3000
PORT=3449 lein run
```
If you want to start the server, you should run `lein mcp-repl` which will output a nrepl-server port file and http-server port file.
## Browser Automation
When using the **agent-browser** skill for testing or automation:
- Navigate to `/dev-login` to simulate an admin user and fake a session
- Do not open directly to a specific page unless explicitly instructed to; instead, start on the dashboard and navigate from there
## Test Execution
prefer using clojure-eval skill

125
CLAUDE.md
View File

@@ -1,125 +1,2 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Integreat is a full-stack web application for accounts payable (AP) and accounting automation. It integrates with multiple financial data sources (Plaid, Yodlee, Square, Intuit QBO) to manage invoices, bank accounts, transactions, and vendor information.
**Tech Stack:**
- Backend: Clojure 1.10.1 with Ring (Jetty), Datomic database, GraphQL (Lacinia, in process of depracation)
- Frontend, two versions:
* ClojureScript with Reagent, Re-frame, (almost depracated)
* Server side: HTMX, TailwindCSS, alpinejs (current)
- Java: Amazon Corretto 11 (required for Clojure 1.10.1)
## Development Commands
**Build:**
```bash
lein build # Create uberjar
lein uberjar # Standalone JAR
npm install # Install Node.js dependencies (for frontend build)
```
### Development
```bash
docker-compose up -d # Start Datomic, Solr services
lein repl # Start Clojure REPL (nREPL on port 9000), typically one will be running for you already
lein cljfmt check # Check code formatting
lein cljfmt fix # Auto-format code
clj-paren-repair [FILE ...] # fix parentheses in files
clj-nrepl-eval -p PORT "(+ 1 2 3)" # evaluate clojure code
```
Often times, if a file won't compile, first clj-paren-repair on the file, then try again. If it doesn't wor still, try cljfmt check.
**Running the Application:**
**As Web Server:**
```bash
INTEGREAT_JOB="" lein run # Default: port 3000
# Or with custom port:
PORT=3449 lein run
```
**As Background Job:**
Set `INTEGREAT_JOB` environment variable to one of:
- `square-import-job` - Square POS transaction sync
- `yodlee2` - Yodlee bank account sync
- `plaid` - Plaid bank linking
- `intuit` - Intuit QBO sync
- `import-uploaded-invoices` - Process uploaded invoice PDFs
- `ezcater-upsert` - EZcater PO sync
- `ledger_reconcile` - Ledger reconciliation
- `bulk_journal_import` - Journal entry import
- (no job) - Run web server + nREPL
## Architecture
**Request Flow:**
1. Ring middleware pipeline processes requests
2. Authentication/authorization middleware (Buddy) wraps handlers
3. Bidi routes dispatch to handlers
4. SSR (server-side rendering) generates HTML with Hiccup for main views
5. For interactive pages, HTMX handles partial updates
6. Client-side uses alpinejs as a bonus
**Multi-tenancy:**
- Client-based filtering via `:client/code` and `:client/groups`
- Client selection via `X-Clients` header or session
- Role-based permissions: admin, standard user, vendor
**Key Directories:**
- `src/clj/auto_ap/` - Backend Clojure code
- `src/clj/auto_ap/server.clj` - Main entry point, job dispatcher, Mount lifecycle
- `src/clj/auto_ap/handler.clj` - Ring app, middleware stack
- `src/clj/auto_ap/datomic/` - Datomic schema and queries
- `src/clj/auto_ap/ssr/` - Server-side rendered page handlers (Hiccup templates)
- `src/clj/auto_ap/routes/` - HTTP route definitions
- `src/clj/auto_ap/jobs/` - Background batch jobs
- `src/clj/auto_ap/graphql/` - GraphQL type definitions and resolvers
- `src/cljs/auto_ap/` - Frontend ClojureScript for old, depracated version
- `test/clj/auto_ap/` - Unit/integration tests
## Database
- Datomic schema defined in `resources/schema.edn`
- Key entity patterns:
- `:client/code`, `:client/groups` for multi-tenancy
- `:vendor/*`, `:invoice/*`, `:transaction/*`, `:account/*` for standard entities
- `:db/type/ref` for relationships, many with `:db/cardinality :db.cardinality/many`
## Configuration
- Dev config: `config/dev.edn` (set via `-Dconfig=config/dev.edn`)
- Env vars: `INTEGREAT_JOB`, `PORT`
- Docker: Uses Alpine-based Amazon Corretto 11 image
## Important Patterns
- **Middleware stack** in `handler.clj`: route matching → logging → client hydration → session/auth → idle timeout → error handling → gzip
- **Client context** added by middleware: `:identity`, `:clients`, `:client`, `:matched-route`
- **Job dispatching** in `server.clj`: checks `INTEGREAT_JOB` env var to run specific background jobs or start web server
- **Test selectors**: namespaces ending in `integration` or `functional` are selected by `lein test :integration` / `lein test :functional`
## Clojure REPL Evaluation
The command `clj-nrepl-eval` is installed on your path for evaluating Clojure code via nREPL.
**Discover nREPL servers:**
`clj-nrepl-eval --discover-ports`
**Evaluate code:**
`clj-nrepl-eval -p <port> "<clojure-code>"`
With timeout (milliseconds)
`clj-nrepl-eval -p <port> --timeout 5000 "<clojure-code>"`
The REPL session persists between evaluations - namespaces and state are maintained.
Always use `:reload` when requiring namespaces to pick up changes.
@AGENTS.md

106
config/core.clj Normal file
View File

@@ -0,0 +1,106 @@
(ns config.core
(:require [clojure.java.io :as io]
[clojure.edn :as edn]
[clojure.string :as s]
[clojure.tools.logging :as log])
(:import java.io.PushbackReader))
(defn parse-number [^String v]
(try
(Long/parseLong v)
(catch NumberFormatException _
(BigInteger. v))))
;originally found in cprop https://github.com/tolitius/cprop/blob/6963f8e04fd093744555f990c93747e0e5889395/src/cprop/source.cljc#L26
(defn str->value
"ENV vars and system properties are strings. str->value will convert:
the numbers to longs, the alphanumeric values to strings, and will use Clojure reader for the rest
in case reader can't read OR it reads a symbol, the value will be returned as is (a string)"
[v]
(cond
(re-matches #"[0-9]+" v) (parse-number v)
(re-matches #"^(true|false)$" v) (Boolean/parseBoolean v)
(re-matches #"\w+" v) v
:else
(try
(let [parsed (edn/read-string v)]
(if (symbol? parsed) v parsed))
(catch Throwable _ v))))
(defn keywordize [s]
(-> (s/lower-case s)
(s/replace "_" "-")
(s/replace "." "-")
(keyword)))
(defn read-system-env []
(->> (System/getenv)
(map (fn [[k v]] [(keywordize k) (str->value v)]))
(into {})))
(defn read-system-props []
(->> (System/getProperties)
(map (fn [[k v]] [(keywordize k) (str->value v)]))
(into {})))
(defn read-env-file [f]
(try
(when-let [env-file (io/file f)]
(when (.exists env-file)
(edn/read-string (slurp env-file))))
(catch Exception e
(log/warn (str "WARNING: failed to parse " f " " (.getLocalizedMessage e))))))
(defn read-config-file [f]
(try
(when-let [url (or (io/resource f) (io/file f))]
(with-open [r (-> url io/reader PushbackReader.)]
(edn/read r)))
(catch java.io.FileNotFoundException _)
(catch Exception e
(log/warn (str "failed to parse " f " " (.getLocalizedMessage e))))))
(defn contains-in?
"checks whether the nested key exists in a map"
[m k-path]
(let [one-before (get-in m (drop-last k-path))]
(when (map? one-before) ;; in case k-path is "longer" than a map: {:a {:b {:c 42}}} => [:a :b :c :d]
(contains? one-before (last k-path)))))
;; author of "deep-merge-with" is Chris Houser: https://github.com/clojure/clojure-contrib/commit/19613025d233b5f445b1dd3460c4128f39218741
(defn deep-merge-with
"Like merge-with, but merges maps recursively, appling the given fn
only when there's a non-map at a particular level.
(deepmerge + {:a {:b {:c 1 :d {:x 1 :y 2}} :e 3} :f 4}
{:a {:b {:c 2 :d {:z 9} :z 3} :e 100}})
-> {:a {:b {:z 3, :c 3, :d {:z 9, :x 1, :y 2}}, :e 103}, :f 4}"
[f & maps]
(apply
(fn m [& maps]
(if (every? map? maps)
(apply merge-with m maps)
(apply f maps)))
(remove nil? maps)))
(defn merge-maps [& m]
(reduce #(deep-merge-with (fn [_ v] v) %1 %2) m))
(defn load-env
"Generate a map of environment variables."
[& configs]
(let [env-props (merge-maps (read-system-env) (read-system-props))]
(apply
merge-maps
(read-config-file "config.edn")
(read-env-file ".lein-env")
(read-env-file (io/resource ".boot-env"))
(read-env-file (:config env-props))
env-props
configs)))
(defonce
^{:doc "A map of environment variables."}
env (load-env))
(defn reload-env []
(alter-var-root #'env (fn [_] (load-env))))

View File

@@ -0,0 +1,298 @@
# Phase I Trial: Django-Formtools Style Wizard (Server-Side Storage)
## Goal
Create a **copy** of one existing wizard using Approach B (server-side session storage) without modifying any existing multi_modal.clj code. This is an isolated trial to validate the pattern.
## Trial Subject: Transaction Bulk Code Wizard
**Why this one:**
- Single-step form (simplest case)
- Currently uses wizard protocols unnecessarily
- ~420 lines in `transaction/bulk_code.clj`
- Well-contained with clear inputs/outputs
---
## Architecture
```
Browser Server
| |
|-- GET /bulk-code-trial -->|
| |-- Create session entry
| |-- Return form with wizard-id
|<-- HTML + wizard-id -----|
| |
|-- POST (wizard-id + -->|
| current-step data) |-- Validate step
| |-- Store in session
| |-- Return next step or done
|<-- HTML -----------------|
```
**Key difference from current approach:**
- Current: EDN snapshot serialized in hidden form fields
- Trial: Only `wizard-id` and current step fields in form. State lives server-side in atom.
---
## Files (All New - No Existing Files Modified)
```
src/clj/auto_ap/ssr/components/wizard_trial/
state.clj - Session storage backend
core.clj - Trial wizard engine (minimal)
src/clj/auto_ap/ssr/transaction/
bulk_code_trial.clj - Trial implementation of bulk code
test/clj/auto_ap/ssr/transaction/
bulk_code_trial_test.clj - Tests for trial
```
---
## Phase I: Trial Implementation (This Week)
### Step 1: Create Session Storage
**File:** `src/clj/auto_ap/ssr/components/wizard_trial/state.clj`
```clojure
(ns auto-ap.ssr.components.wizard-trial.state)
(defonce ^:private store (atom {}))
(defn create!
"Creates new wizard session. Returns wizard-id."
[initial-data]
(let [id (str (java.util.UUID/randomUUID))]
(swap! store assoc id {:data initial-data
:created-at (java.util.Date.)})
id))
(defn get-wizard
"Retrieves wizard data by id."
[id]
(get @store id))
(defn update-step!
"Merges step data into wizard session."
[id step-key step-data]
(swap! store assoc-in [id :data step-key] step-data))
(defn get-all-data
"Returns merged data from all steps."
[id]
(-> (get-wizard id)
:data
vals
(apply merge)))
(defn destroy!
"Removes wizard session."
[id]
(swap! store dissoc id))
```
### Step 2: Create Minimal Wizard Engine
**File:** `src/clj/auto_ap/ssr/components/wizard_trial/core.clj`
```clojure
(ns auto-ap.ssr.components.wizard-trial.core
(:require [auto-ap.ssr.components.wizard-trial.state :as ws]
[malli.core :as mc]))
(defn render-step
"Renders a single step form."
[{:keys [wizard-id step-config request]}]
(let [step-data (get-in (ws/get-wizard wizard-id) [:data (:key step-config)])]
[:form {:hx-post (:submit-route step-config)
:hx-target "this"
:hx-swap "outerHTML"}
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
[:input {:type "hidden" :name "step-key" :value (name (:key step-config))}]
((:render step-config) (assoc request :step-data step-data))]))
(defn handle-submit
"Handles step submission."
[step-config request]
(let [{:keys [wizard-id step-key]} (:form-params request)
wizard-id (str wizard-id)
step-key (keyword step-key)
step-data (select-keys (:form-params request) (:fields step-config))]
(if-let [errors (mc/explain (:schema step-config) step-data)]
;; Validation failed - re-render with errors
(render-step {:wizard-id wizard-id
:step-config step-config
:request (assoc request :errors errors)})
;; Success - save and done (single step for trial)
(let [all-data (ws/get-all-data wizard-id)]
(ws/destroy! wizard-id)
((:done-fn step-config) all-data request)))))
```
### Step 3: Create Trial Bulk Code Form
**File:** `src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj`
```clojure
(ns auto-ap.ssr.transaction.bulk-code-trial
(:require [auto-ap.ssr.components.wizard-trial.core :as wt]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.utils :refer [html-response]]
[auto-ap.datomic :refer [conn pull-attr]]
[datomic.api :as dc]))
(def bulk-code-schema
[:map
[:vendor {:optional true} [:maybe int?]]
[:approval-status {:optional true} [:maybe keyword?]]
[:accounts {:optional true}
[:vector {:coerce? true}
[:map
[:account int?]
[:location :string]
[:percentage :double]]]]]])
(defn render-bulk-code-form
"Pure function - renders the form given data."
[{:keys [step-data errors]}]
[:div.bulk-code-trial
[:h2 "Bulk Code (Trial - Phase I)"]
[:div.space-y-4
;; Vendor field
[:div
[:label "Vendor"]
(com/typeahead {:name "vendor"
:value (:vendor step-data)
:url "/api/vendors/search"})]
;; Accounts table
[:div
[:h3 "Accounts"]
[:table
[:thead
[:tr
[:th "Account"]
[:th "Location"]
[:th "%"]]]
[:tbody
(for [[idx account] (map-indexed vector (:accounts step-data []))]
[:tr {:key idx}
[:td (com/typeahead {:name (str "accounts[" idx "][account]")
:value (:account account)})]
[:td (com/text-input {:name (str "accounts[" idx "][location]")
:value (:location account)})]
[:td (com/money-input {:name (str "accounts[" idx "][percentage]")
:value (:percentage account)})]])]]]
;; Submit
[:button {:type "submit"} "Apply Bulk Code"]]])
(def trial-step-config
{:key :bulk-code
:schema bulk-code-schema
:fields [:vendor :approval-status :accounts]
:render render-bulk-code-form
:submit-route "/transaction/bulk-code-trial"
:done-fn (fn [data request]
;; Apply bulk coding logic here
(html-response [:div.success "Bulk code applied! (Trial)"]))})
;; Route handlers
(defn open-trial [request]
(let [wizard-id (ws/create! {:accounts []})]
(wt/render-step {:wizard-id wizard-id
:step-config trial-step-config
:request request})))
(defn submit-trial [request]
(wt/handle-submit trial-step-config request))
```
### Step 4: Add Routes
In your routes file (new entries, don't modify existing):
```clojure
{::route/bulk-code-trial open-trial
::route/bulk-code-trial-submit submit-trial}
```
---
## Phase II: Expand Trial (If Phase I Works)
**Goal:** Test with a multi-step wizard
**Subject:** New Invoice Wizard (2-3 steps)
- Step 1: Basic details
- Step 2: Accounts (conditional)
- Step 3: Submit
**New additions to trial engine:**
- Step navigation (next/prev)
- Conditional steps (skip accounts if not customizing)
- Step validation per-step
- Progress indicator
**Files to create:**
```
src/clj/auto_ap/ssr/invoice/
new_invoice_trial.clj
```
---
## Phase III: Full Migration Decision (If Phase II Works)
**Goal:** Decide whether to migrate all wizards or keep both systems
**Evaluation criteria:**
1. ✅ Line count reduction (target: 50%+)
2. ✅ Testability (pure functions easier to test)
3. ✅ Performance (server-side storage vs EDN serialization)
4. ✅ Complexity (fewer protocols/middleware)
5. ⚠️ Session handling (what happens on server restart?)
6. ⚠️ Multiple tabs (can user have two wizards open?)
**Decision matrix:**
| Criteria | Current | Trial | Winner |
|----------|---------|-------|--------|
| Lines of code | ~8,200 | ~3,000 (est.) | Trial |
| Server restarts | Survives (state in form) | Loses state | Current |
| Multiple tabs | Works (independent forms) | Needs separate IDs | Tie |
| Testability | Hard (cursor context) | Easy (pure functions) | Trial |
| Complex merges | Painful | Simple (keyed steps) | Trial |
**If trial wins:** Migrate all wizards using Phase II pattern
**If mixed:** Use trial for simple forms, keep current for complex multi-step
---
## How to Run the Trial
1. **Start server:** `lein run`
2. **Navigate to:** `/transaction/bulk-code-trial`
3. **Test:** Fill form, submit, verify state handling
4. **Compare:** Open existing `/transaction/bulk-code` in another tab
5. **Evaluate:** Which feels simpler? Which is easier to debug?
---
## Success Criteria for Phase I
- [ ] Trial renders without errors
- [ ] Form submission validates correctly
- [ ] Server-side state persists across requests
- [ ] No modifications to existing multi_modal.clj
- [ ] Code is < 200 lines (vs 420 original)
- [ ] Developer can understand flow in 5 minutes
**Ready to implement Phase I?**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,601 @@
# Transaction Edit Modal: Simple / Advanced Mode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the always-visible account split table in the transaction edit modal with a simple mode (single account + location fields) that is the default for uncoded or single-account transactions, with a toggle to the full advanced split table.
**Architecture:** HTMX-driven server-side swap. A new `edit-wizard-toggle-mode` GET endpoint re-renders the manual coding section in the requested mode. Mode is carried via a hidden `<input name="mode">` field included in all HTMX requests. `edit-vendor-changed` is updated to branch on mode. The `LinksStep` render function selects initial mode based on account row count.
**Tech Stack:** Clojure/Hiccup server-side rendering, HTMX, Alpine.js, Bidi routing, Datomic
**Spec:** `docs/superpowers/specs/2026-05-27-transaction-edit-simple-advanced-mode-design.md`
---
## File Map
| File | What changes |
|------|-------------|
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add `simple-mode-fields*`, `manual-coding-section*`, `edit-wizard-toggle-mode-handler`; update `LinksStep` render; update `edit-vendor-changed-handler`; register new route handler |
---
## Task 1: Add the toggle-mode route
**Files:**
- Modify: `src/cljc/auto_ap/routes/transactions.cljc`
- [ ] **Step 1: Add the route entry**
Open `src/cljc/auto_ap/routes/transactions.cljc`. After the `"/edit-wizard-new-account"` line, add:
```clojure
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
```
The file should look like:
```clojure
"/edit-wizard-new-account" ::edit-wizard-new-account
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
"/match-payment" ::link-payment
```
- [ ] **Step 2: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.routes.transactions] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/cljc/auto_ap/routes/transactions.cljc
git commit -m "feat: add edit-wizard-toggle-mode route"
```
---
## Task 2: Add `simple-mode-fields*` — the simple-mode account/location UI
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
This function renders the account typeahead + location select + toggle link for simple mode. It goes near the existing `account-typeahead*` and `location-select*` helpers (around line 180).
- [ ] **Step 1: Add `simple-mode-fields*` after `account-typeahead*` (around line 180)**
```clojure
(defn simple-mode-fields*
"Renders the simple-mode account + location row and the toggle link.
request must have :multi-form-state and :entity bound."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
client-id (or (-> request :entity :transaction/client :db/id)
(:transaction/client snapshot))
existing-row (first (:transaction/accounts snapshot))
account-val (:transaction-account/account existing-row)
location-val (or (:transaction-account/location existing-row) "Shared")
account-id (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val))
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))]
[:div
;; hidden inputs to encode the single row as transaction/accounts[0]
(fc/with-field :transaction/accounts
(fc/with-cursor-index 0
[:span
(fc/with-field :db/id
(com/hidden {:name (fc/field-name) :value row-id}))
[:div.flex.gap-2.mt-2
(fc/with-field :transaction-account/account
(com/validated-field
{:label "Account" :errors (fc/field-errors)}
[:div.w-72
(account-typeahead* {:value account-val
:client-id client-id
:name (fc/field-name)
:x-model "simpleAccountId"})]))
(fc/with-field :transaction-account/location
(com/validated-field
{:label "Location"
:errors (fc/field-errors)
:x-hx-val:account-id "simpleAccountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(location-select*
{:name (fc/field-name)
:account-location (:account/location account-id)
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value location-val})))
;; hidden amount — full transaction total
(fc/with-field :transaction-account/amount
(let [total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))]
(com/hidden {:name (fc/field-name) :value total})))]))
;; toggle link
[:div.mt-1
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to advanced mode"]]]))
```
- [ ] **Step 2: Verify the file has no parse errors**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: add simple-mode-fields* for transaction edit modal"
```
---
## Task 3: Extract `manual-coding-section*` and update `LinksStep`
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
Currently the manual coding block (vendor field + account grid) is inlined inside `LinksStep/render-step`. Extract it into `manual-coding-section*` which selects mode and renders accordingly. This also adds the `mode` hidden input and wraps the section in `#manual-coding-section`.
- [ ] **Step 1: Add `manual-mode-initial` helper** (determines initial mode from snapshot)
Add this function after `simple-mode-fields*`:
```clojure
(defn- manual-mode-initial
"Returns :simple or :advanced based on existing account row count."
[snapshot]
(let [rows (seq (:transaction/accounts snapshot))]
(if (and rows (> (count rows) 1))
:advanced
:simple)))
```
- [ ] **Step 2: Add `manual-coding-section*`**
Add after `manual-mode-initial`:
```clojure
(defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab.
mode is :simple or :advanced."
[mode request]
(let [snapshot (-> request :multi-form-state :snapshot)
row-count (count (:transaction/accounts snapshot))]
[:div#manual-coding-section
;; hidden mode input — carried by all hx-include=\"closest form\" calls
(com/hidden {:name "mode" :value (name mode)})
;; vendor field
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
(com/validated-field
{:label "Vendor" :errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
;; account/location section
(if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId
(-> snapshot :transaction/accounts first
:transaction-account/account)})}
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(simple-mode-fields* request)))]
;; advanced mode
[:div
(when (<= row-count 1)
[:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to simple mode"]])
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(fc/with-field :transaction/accounts
[:div#account-grid-body
(account-grid-body* request)])))])]))
```
- [ ] **Step 3: Update `LinksStep/render-step` to use `manual-coding-section*`**
In `LinksStep/render-step` (around line 826), replace the entire `[:div {}` block inside `[:div {:x-show "activeForm === 'manual'" ...}]` (which currently contains the vendor typeahead + approval status + `account-grid-body*`) with:
```clojure
[: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 {}
(manual-coding-section* (manual-mode-initial snapshot) request)
;; Approval status field
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
:errors (fc/field-errors)}
(let [current-value (name (or (fc/field-value) :transaction-approval-status/unapproved))]
[:div {:x-data (hx/json {:approvalStatus current-value})}
(com/hidden {:name (fc/field-name)
:value current-value
":value" "approvalStatus"})
[:div {:class "inline-flex rounded-md shadow-sm", :role "group"}
(com/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 = 'unapproved'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }"
:class "rounded-r-lg"}
"Unapproved")
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
:class "rounded-r-lg"}
"Client Review")]]]))]]]
```
Also remove the now-redundant `(fc/with-field :transaction/accounts ...)` wrapper that previously wrapped `account-grid-body*` (it is now handled inside `manual-coding-section*`).
- [ ] **Step 4: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: extract manual-coding-section* with simple/advanced mode selection"
```
---
## Task 4: Add `edit-wizard-toggle-mode-handler`
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
This handler re-renders `#manual-coding-section` in the opposite mode. It reads `mode` from the form params (via `step-params` in the decoded multi-form-state) and flips it.
- [ ] **Step 1: Add the handler function** (add after `edit-vendor-changed-handler`):
```clojure
(defn edit-wizard-toggle-mode-handler [request]
(let [step-params (-> request :multi-form-state :step-params)
current-mode (keyword (or (:mode step-params) "simple"))
target-mode (if (= current-mode :simple) :advanced :simple)
snapshot (-> request :multi-form-state :snapshot)
;; When switching simple→advanced, promote simple-mode values into accounts
render-request
(if (and (= target-mode :advanced)
(= current-mode :simple))
;; carry the simple-mode single row into snapshot so the table shows it
(let [accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] [])))))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* target-mode render-request))))))
```
- [ ] **Step 2: Register the handler in `key->handler`**
In the `key->handler` map (around line 1357), add after the `::route/edit-wizard-new-account` entry:
```clojure
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
```
- [ ] **Step 3: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: add edit-wizard-toggle-mode-handler"
```
---
## Task 5: Update `edit-vendor-changed-handler` to support both modes
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
Currently this handler always returns `[:div#account-grid-body ...]`. It must now return `#manual-coding-section` in the correct mode.
- [ ] **Step 1: Replace `edit-vendor-changed-handler`**
Replace the entire `edit-vendor-changed-handler` function body with:
```clojure
(defn edit-vendor-changed-handler [request]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params) "simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params)
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
default-account (when (and (empty? existing-accounts) vendor-id client-id)
(vendor-default-account vendor-id client-id))
render-request
(if (and (empty? existing-accounts) vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* mode render-request))))))
```
Note: the `hx-target` on the vendor field in `manual-coding-section*` must point to `#manual-coding-section` (not `#account-grid-body`) — this was set correctly in Task 3.
- [ ] **Step 2: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: update edit-vendor-changed-handler to support simple/advanced mode"
```
---
## Task 6: Fix `fc/with-cursor-index` usage in `simple-mode-fields*`
**Context:** The simple-mode fields need to emit form names like `step-params[transaction/accounts][0][transaction-account/account]`. The existing `fc/cursor-map` used in `account-grid-body*` handles this automatically. For the single-row simple mode we need to manually set index 0.
Look up how `fc/with-cursor-index` (or equivalent) works in `src/clj/auto_ap/ssr/form_cursor.clj` before writing the code in Task 2. If no such helper exists, use `fc/cursor-nth` or replicate the index manually via the form cursor API.
- [ ] **Step 1: Inspect the form cursor API**
```bash
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-vars 'auto-ap.ssr.form-cursor)"
```
Note the available functions.
- [ ] **Step 2: Update `simple-mode-fields*` if needed**
If `fc/with-cursor-index` does not exist, replace the `fc/with-cursor-index 0` call in Task 2 with the correct form-cursor idiom. The key requirement is that the hidden `db/id`, `transaction-account/account`, `transaction-account/location`, and `transaction-account/amount` fields emit names matching index 0 of `transaction/accounts`.
A known working pattern from `account-grid-body*`:
```clojure
(fc/cursor-map #(transaction-account-row* {:value % ...}))
```
For simple mode with a single synthetic row, build a one-element vector in the snapshot and let `fc/cursor-map` iterate it — but render a flat div instead of a table. Or pass the cursor manually:
```clojure
(fc/with-field :transaction/accounts
(let [row-cursor (fc/cursor-nth 0)] ; adjust to actual API
(fc/with-cursor row-cursor
...field rendering...)))
```
Verify field names are correct in a browser after implementation.
- [ ] **Step 3: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
- [ ] **Step 4: Commit if any changes were made**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "fix: correct form-cursor indexing for simple-mode account field"
```
---
## Task 7: Manual smoke test
Before writing automated tests, verify the UI works end-to-end in a browser.
- [ ] **Step 1: Start the application**
```bash
INTEGREAT_JOB="" lein run
```
- [ ] **Step 2: Open a transaction with no accounts**
Navigate to a transaction with no coded accounts. Open the edit modal. Verify it opens in simple mode with blank account and location fields.
- [ ] **Step 3: Test vendor selection in simple mode**
Select a vendor. Verify the account field is populated with the vendor's default account and the location is set appropriately.
- [ ] **Step 4: Test toggle to advanced**
Click "Switch to advanced mode". Verify the full split table appears with one pre-populated row.
- [ ] **Step 5: Test toggle back to simple**
With 1 row, click "Switch to simple mode". Verify the single account/location fields appear with that row's values.
- [ ] **Step 6: Test with a split transaction**
Open a transaction that already has 2+ accounts. Verify it opens in advanced mode. Verify the "Switch to simple mode" link is absent.
- [ ] **Step 7: Test save round-trip**
In simple mode, set a vendor, account, and location. Save. Re-open. Verify the same values are pre-populated in simple mode.
---
## Task 8: Write e2e tests
**Files:**
- Create: `test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj`
Check how existing e2e tests are structured first:
```bash
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-ns)"
```
Look for test namespaces matching `auto-ap.ssr.transaction.*` or `auto-ap.e2e.*`. Follow the same fixture/helper patterns.
The test file should cover all 20 acceptance criteria from the spec. Group them with `testing` blocks:
```clojure
(ns auto-ap.ssr.transaction.edit-simple-advanced-mode-test
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
;; ... project-specific test helpers ...
))
(deftest simple-advanced-mode-initial-state
(testing "AC1: uncoded transaction opens in simple mode"
;; create a transaction with no accounts
;; open edit modal
;; verify #manual-coding-section has mode=simple hidden input
;; verify no #account-grid-body present
(is ...))
(testing "AC2: single-account transaction opens in simple mode with values pre-populated"
...)
(testing "AC3: multi-account transaction opens in advanced mode"
...))
(deftest simple-mode-vendor-selection
(testing "AC4: selecting vendor populates account and location"
...)
(testing "AC5: selecting vendor does not overwrite manually chosen account"
...))
(deftest mode-toggle
(testing "AC9: switching to advanced carries account/location into first row"
...)
(testing "AC10: switching to advanced from blank simple gives empty table"
...)
(testing "AC11: switch-to-simple link visible with 0 or 1 rows"
...)
(testing "AC12: switch-to-simple link absent with 2+ rows"
...)
(testing "AC13: switching to simple pre-populates from first row"
...))
(deftest save-round-trip
(testing "AC6: save in simple mode persists vendor/account/location"
...)
(testing "AC18: switching modes mid-edit then saving produces valid transaction"
...)
(testing "AC19: split transaction re-opens in advanced mode with splits intact"
...)
(testing "AC20: single-account transaction re-opens in simple mode"
...))
```
Fill in actual test bodies using the project's test infrastructure (browser automation or ring mock depending on what exists).
- [ ] **Step 1: Check existing test conventions**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.testing-conventions] :reload)"
```
Also load the testing-conventions skill for guidance:
```
Load skill: testing-conventions
```
- [ ] **Step 2: Write the test file** following project conventions
- [ ] **Step 3: Run the tests**
```bash
clj-nrepl-eval -p 9000 "(clojure.test/run-tests 'auto-ap.ssr.transaction.edit-simple-advanced-mode-test)"
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj
git commit -m "test: add e2e acceptance tests for simple/advanced mode"
```
---
## Self-Review Checklist (completed inline)
- **Spec coverage:** All 20 ACs addressed — Tasks 25 implement the behaviour; Task 8 tests all 20.
- **Placeholder scan:** Task 6 and Task 8 contain some "fill in" guidance — this is intentional because they depend on runtime API discovery. The instructions tell the engineer exactly where to look and what to verify.
- **Type consistency:** `manual-coding-section*` is used consistently by `LinksStep/render-step`, `edit-vendor-changed-handler`, and `edit-wizard-toggle-mode-handler`. `#manual-coding-section` is the swap target throughout. `mode` hidden input uses `(name mode)` for string serialization and `(keyword ...)` for deserialization.

View File

@@ -0,0 +1,132 @@
# Transaction Edit Modal: Simple / Advanced Mode
**Date:** 2026-05-27
**Status:** Approved
---
## Overview
The transaction editing modal gains a two-mode interface. **Simple mode** replaces the account/location split table with two single fields (account typeahead + location dropdown), suitable for the common case of a single-account transaction. **Advanced mode** exposes the existing split table for multi-account allocations. The mode is selected automatically on open based on the transaction's current state, and the user can toggle between modes via a server-rendered swap.
---
## Layout
### Simple mode
```
[ Vendor typeahead ]
[ Account typeahead ] [ Location ▼ ]
Switch to advanced mode →
[ Memo ]
[ Approval status buttons ]
```
### Advanced mode
```
[ Vendor typeahead ]
Switch to simple mode →
[ Account | Location | $ / % | ✕ ]
[ Account | Location | $ / % | ✕ ]
[ + Add row ]
[ Memo ]
[ Approval status buttons ]
```
The toggle link sits directly below the vendor field. It reads "Switch to advanced mode" in simple mode and "Switch to simple mode" in advanced mode. In advanced mode with 2+ rows, the "Switch to simple mode" link is hidden (the user must remove rows manually before they can return to simple mode). The toggle link fires `hx-get` on the `::route/edit-wizard-toggle-mode` endpoint with `hx-include="closest form"` so the current form state (including `mode`) is carried in the request.
---
## Mode Selection on Open
The server determines initial mode when rendering the `LinksStep` body:
- **0 or 1 existing account rows** → render simple mode, pre-populate the account/location fields from the existing row (blank if none).
- **2+ existing account rows** → render advanced mode with all rows populated.
---
## Toggle Mechanism (Option B — HTMX swap)
Clicking the toggle link fires an `hx-get` request to a new endpoint that re-renders the editable body of the modal in the target mode. Mode is passed as a query param (e.g., `?mode=advanced` or `?mode=simple`).
**Simple → Advanced:** The current account and location values from the simple fields are carried into the first row of the advanced table (100% of transaction total, or full dollar amount). Any additional rows previously added to the table are preserved via the multi-form-state snapshot.
**Advanced → Simple:** Only available when there is exactly 0 or 1 row in the table. The toggle link is absent when 2+ rows exist.
The swapped fragment replaces the entire editable body div (`#links-step-body` or equivalent target), keeping the side panel and modal chrome intact.
The current mode is tracked as a hidden `<input name="mode" value="simple|advanced">` inside the form. This ensures all HTMX calls that `hx-include` the form (vendor change, toggle, submit) carry the mode value without requiring it to be a separate query param.
---
## Vendor Selection Behaviour
### In simple mode
When the vendor typeahead fires its `change` event, the existing `edit-vendor-changed` HTMX endpoint is called. The response re-renders the simple-mode body with:
- The account field populated with the vendor's default account (clientized for the transaction's client).
- The location field set to the account's fixed location, or "Shared" if the account has no fixed location.
- Fields are editable; the user may override both.
The vendor default only applies when there are no existing accounts (matching existing server-side logic in `edit-vendor-changed-handler`). If the user has already manually chosen an account, changing vendor does not overwrite it.
### In advanced mode
Vendor change behaviour is unchanged from the current implementation: if no rows exist, a single row is created with the vendor's default account and location at 100% / full amount. If rows already exist, the vendor change has no effect on the table.
---
## Form Submission
Both modes submit to the same `edit-submit` endpoint. Simple mode submits the single account and location as a one-element `transaction/accounts` vector, identical in shape to what the advanced table produces today. No schema or handler changes are needed for submission.
---
## New Routes
| Method | Route key | Purpose |
|--------|-----------|---------|
| `GET` | `::route/edit-wizard-toggle-mode` | Re-renders the editable body in the requested mode. Reads current form state from `hx-include`'d form fields; the `mode` hidden input indicates the target mode (the endpoint flips it). |
The `edit-vendor-changed` endpoint reads the `mode` hidden input from the included form to determine whether to return simple-mode or advanced-mode HTML.
---
## Acceptance Criteria
These are the expected behaviours to be verified by e2e tests:
1. **Verify** that when a transaction has no coded accounts, it opens in simple mode with blank account and location fields.
2. **Verify** that when a transaction has exactly one coded account, it opens in simple mode with that account and location pre-selected.
3. **Verify** that when a transaction has two or more coded accounts, it opens in advanced mode showing the full split table.
4. **Verify** that in simple mode, selecting a vendor replaces the account field with the vendor's default account (clientized for the transaction's client) and sets the location to the account's fixed location or "Shared".
5. **Verify** that in simple mode, selecting a vendor does not overwrite an account the user has already manually chosen (i.e., the account typeahead already has a value when the vendor change fires).
6. **Verify** that after saving in simple mode, re-opening the transaction shows the same vendor, account, and location that were saved.
7. **Verify** that in simple mode, the account field is a typeahead that respects the same allowance rules as the advanced table (`:account/default-allowance`).
8. **Verify** that in simple mode, the location dropdown shows the account's fixed location (sole option) if the account has one, or the full list of client locations plus "Shared" if it does not.
9. **Verify** that clicking "Switch to advanced mode" from simple mode re-renders the form in advanced mode with one table row pre-populated from the simple-mode account and location fields.
10. **Verify** that clicking "Switch to advanced mode" from a blank simple mode (no account selected) re-renders the form in advanced mode with an empty table (no rows, just the "Add row" button).
11. **Verify** that the "Switch to simple mode" link is visible in advanced mode when there is exactly 0 or 1 row.
12. **Verify** that the "Switch to simple mode" link is absent in advanced mode when there are 2 or more rows.
13. **Verify** that clicking "Switch to simple mode" from advanced mode (1 row) re-renders the form in simple mode with that row's account and location pre-populated.
14. **Verify** that in advanced mode, selecting a vendor when there are no rows creates a single row with the vendor's default account, correct location, and 100% (or full dollar amount) allocation.
15. **Verify** that in advanced mode, selecting a vendor when rows already exist does not modify the existing rows.
16. **Verify** that the vendor default account is determined by clientizing the vendor for the client the transaction belongs to (client-specific account override takes precedence over the global vendor default).
17. **Verify** that the approval status, memo, and vendor fields are present and functional in both simple and advanced modes.
18. **Verify** that switching modes mid-edit and then saving produces a valid transaction (no orphaned or duplicated account rows).
19. **Verify** that a transaction saved in advanced mode with splits can be re-opened and remains in advanced mode with all splits intact.
20. **Verify** that a transaction saved in simple mode (single account) can be re-opened in simple mode and the single account/location are correctly pre-populated.
---
## Files Affected
| File | Change |
|------|--------|
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add simple-mode rendering functions; add `edit-wizard-toggle-mode` handler; update `edit-vendor-changed-handler` to support both modes; update `LinksStep` body render to select initial mode |
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
| E2E test file (to be created) | Acceptance criteria tests for all 20 items above |

View File

@@ -118,7 +118,12 @@
"type": "local",
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
"enabled": true
}
} ,
"tavily": {
"type": "remote",
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey=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",
"enabled": true
}
},
"permission": {
"read": "allow",

View File

@@ -119,6 +119,7 @@
:aliases {"build" ["do" ["uberjar"]]
#_#_"fig:dev" ["run" "-m" "figwheel.main" "-b" "dev" "-r"]
"build-dev" ["trampoline" "run" "-m" "figwheel.main" "-b" "dev" "-r"]
"mcp-repl" ["trampoline" "run" "-m" "dev-mcp"]
#_#_"fig:min" ["run" "-m" "figwheel.main" "-O" "whitespace" "-bo" "min"]}

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,12 @@
"sourceType": "github",
"skillPath": "skills/agent-browser/SKILL.md",
"computedHash": "228f87d57035100d9dc6efcfc05aafd4b6e3962adacaa04b8217ab2fadb15dc8"
},
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
}
}
}

View File

@@ -66,9 +66,9 @@
])
(defn not-found [_]
{:status 404
{:status 404
:headers {}
:body ""})
:body ""})
(defn home-handler [{:keys [identity]}]
(if identity
@@ -125,13 +125,13 @@
(defn wrap-logging [handler]
(fn [request]
(mu/with-context (cond-> {:uri (:uri request)
:route (:handler (bidi.bidi/match-route all-routes
(:uri request)
:request-method (:request-method request)))
(mu/with-context (cond-> {:uri (:uri request)
:route (:handler (bidi.bidi/match-route all-routes
(:uri request)
:request-method (:request-method request)))
:client-selection (:client-selection request)
:source "request"
:source "request"
:query (:uri request)
:request-method (:request-method request)
:user (dissoc (:identity request)
@@ -157,15 +157,15 @@
(defn wrap-idle-session-timeout
[handler]
(fn [request]
(let [session (:session request {:version session-version/current-session-version})
(let [session (:session request {:version session-version/current-session-version})
end-time (coerce/to-date-time (::idle-timeout session))]
(if (and end-time (time/before? end-time (time/now)))
(if (get (:headers request) "hx-request")
{:session nil
:status 200
:status 200
:headers {"hx-redirect" "/login"}}
{:session nil
:status 302
:status 302
:headers {"Location" "/login"}})
(when-let [response (handler request)]
(let [session (:session response session)]
@@ -231,7 +231,7 @@
seq
(pull-many (dc/db conn)
'[:db/id :client/name :client/code :client/locations
:client/matches :client/feature-flags
:client/matches :client/feature-flags
{:client/bank-accounts [:db/id
{:bank-account/type [:db/ident]}
:bank-account/number
@@ -298,7 +298,7 @@
{:status 200
:headers {"hx-trigger" (cheshire/generate-string
{"notification" (str (hiccup/html [:div (.getMessage e)]))})
"hx-reswap" "none"}} ;; TODO make a warning box so you don't have to reuse the notifaction box, or make it reuse the same box but theme differently
"hx-reswap" "none"}} ;; TODO make a warning box so you don't have to reuse the notifaction box, or make it reuse the same box but theme differently
:else
{:status 500
:body (pr-str e)})))))
@@ -315,32 +315,48 @@
:valid-trimmed-client-ids trimmed-clients
:first-client-id (first valid-clients)
:clients-trimmed? (not= (count trimmed-clients) (count valid-clients)))))))
(defn wrap-dev-login [handler]
(fn [request]
(if (and (= "/dev-login" (:uri request))
(some-> env :base-url (.contains "localhost")))
(let [identity {:user "Dev User"
:user/name "Dev User"
:user/role "admin"
:db/id 0}]
{:status 200
:headers {"Content-Type" "text/html"}
:body "<p>Logged in as Dev User!</p><a href='/dashboard'>Continue to dashboard</a>"
:session {:identity identity
:version session-version/current-session-version}})
(handler request))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defonce app
(-> route-handler
(wrap-hx-current-url-params)
(wrap-guess-route)
(wrap-logging)
(wrap-trim-clients)
(wrap-hydrate-clients)
(wrap-store-client-in-session)
(wrap-gunzip-jwt)
(wrap-authorization auth-backend)
(wrap-authentication auth-backend
(session-backend {:authfn (fn [auth]
(dissoc auth :exp))}))
(-> route-handler
(wrap-hx-current-url-params)
(wrap-guess-route)
(wrap-logging)
(wrap-trim-clients)
(wrap-hydrate-clients)
(wrap-store-client-in-session)
(wrap-gunzip-jwt)
(wrap-dev-login)
(wrap-authorization auth-backend)
(wrap-authentication auth-backend
(session-backend {:authfn (fn [auth]
(dissoc auth :exp))}))
#_(wrap-pprint-session)
#_(wrap-pprint-session)
(session-version/wrap-session-version)
(wrap-idle-session-timeout)
(wrap-session {:store (cookie-store
{:key
(byte-array
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
(session-version/wrap-session-version)
(wrap-idle-session-timeout)
(wrap-session {:store (cookie-store
{:key
(byte-array
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
#_(wrap-reload)
(wrap-params)
(mp/wrap-multipart-params)
(wrap-edn-params)
(wrap-error)))
#_(wrap-reload)
(wrap-params)
(mp/wrap-multipart-params)
(wrap-edn-params)
(wrap-error)))

View File

@@ -62,7 +62,9 @@
(.setHandler server stats-handler))
(.setStopAtShutdown server true))
(mount/defstate port :start (Integer/parseInt (str (or (env :port) "3000"))))
(def ^:dynamic *http-port-override* nil)
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))
(mount/defstate jetty
:start (run-jetty app {:port port
@@ -82,7 +84,7 @@
(statsd/gauge "requests.5xx" (double (.getResponses5xx (.getHandler jetty))))
(.statsReset (.getHandler jetty))
(catch Exception e
(alog/warn ::cant-collect-stats :error e))))
(alog/warn ::cant-collect-stats :error e))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(mount/defstate jetty-stats

View File

@@ -1,12 +1,11 @@
(ns auto-ap.ssr.auth
(:require
[auto-ap.session-version :as session-version]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.ui :refer [base-page]]
[buddy.sign.jwt :as jwt]
[config.core :refer [env]]
[hiccup2.core :as hiccup]
[hiccup.util :as hu]))
(defn logout [request]
@@ -15,7 +14,7 @@
:session {}})
(defn impersonate [request]
{:status 200
{:status 200
:session {:identity (dissoc (jwt/unsign (get-in request [:query-params "jwt"])
(:jwt-secret env)
{:alg :hs512})
@@ -37,68 +36,73 @@
"scope" "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"}
next (assoc "state" (hu/url-encode next))))))))
(defn- login-page [contents]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "<!DOCTYPE html>"
(hiccup/html
[:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:title "Integreat · Sign In"]
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
[:link {:rel "stylesheet" :href "/output.css"}]
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}]
[:style
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}"]]
[:body contents]]))})
(defn- page-contents [request]
[:div#app {"@notification.document" "notificationDetails=event.detail.value; showNotification=true"
[:div
{:x-data (hx/json {:showError false
:errorDetails ""})
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"}
:x-data (hx/json {:showError false
:errorDetails ""
:showNotification false
:notificationDetails ""})
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"}
[:div#app-contents.flex.overflow-hidden
[:div#main-content {:class "relative w-full h-full overflow-y-auto px-4 bg-gray-100 dark:bg-gray-900 min-h-content "}
[:div#notification-holder
[:div.fixed.top-0.right-0.left-0.z-30.mx-auto.max-w-screen-lg.w-screen-lg.my-0.pt-8.rounded-lg {:x-show "showNotification"}
[:div.relative
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-blue-400
{"@click" "showNotification=false"}
svg/filled-x]]
[:div.fixed.top-0.left-0.right-0.z-50.mx-auto.max-w-md.w-full.px-4.pt-6
{:x-show "showError"
"x-transition:enter" "transition duration-200 ease-out"
"x-transition:enter-start" "opacity-0 -translate-y-3"
"x-transition:enter-end" "opacity-100 translate-y-0"}
[:div.relative.bg-white.rounded-xl.shadow-xl.border.border-red-200.p-4
[:button.absolute.right-3.top-3.p-1.text-red-400.hover:text-red-600
{"@click" "showError=false"}
svg/filled-x]
[:div.flex.items-start.gap-3
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
[:div.flex-1.min-w-0
[:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
[:p.text-xs.text-gray-500.mt-0.5
"Our team has been notified. Please try again."
[:span {:x-data (hx/json {"e" false})}
" "
[:a.text-xs.underline.cursor-pointer.text-gray-500.hover:text-gray-700
{"@click" "e=true"}
"Details"]
[:pre.text-xs.mt-1.font-mono.text-red-600.bg-red-50.p-2.rounded {:x-show "e" :x-text "errorDetails"}]]]]]]]
[:div.m-4.overflow-auto.z-30.flex.center-items.justify-center.text-blue-800.bg-blue-50.dark:bg-gray-800.dark:text-blue-400.border-blue-300.rounded-lg.border.max-h-96
{:x-show "showNotification"
"x-transition:enter" "transition duration-300 transform ease-in-out"
"x-transition:enter-start" "opacity-0 translate-y-full"
"x-transition:enter-end" "opacity-100 translate-y-0"
"x-transition:leave" "transition duration-300 transform ease-in-out"
"x-transition:leave-start" "opacity-100 translate-y-0"
"x-transition:leave-end" "opacity-0 translate-y-full"}
[:div.flex.items-center.justify-center.min-h-screen.px-4
[:div.w-full.max-w-lg
[:div.flex.flex-col.items-center.mb-10
[:img {:src "/img/logo-big.png" :alt "Integreat" :class "h-16 brightness-0 invert"}]]
[:div {:class "p-4 text-lg w-full" :role "alert"}
[:div.text-sm
[:pre#notification-details.text-xs {:x-html "notificationDetails"}]]]]]]
[:div {:x-show "showError"
:x-init ""}
[:div.fixed.top-0.right-0.left-0.z-30.mx-auto.max-w-screen-lg.w-screen-lg.my-0.pt-8.rounded-lg
[:div.relative
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-red-600
{"@click" "showError=false"}
svg/filled-x]]
[:div.bg-white.rounded-2xl.shadow-2xl.p-10
{:style "animation: slideUp 0.4s ease-out forwards; opacity: 0;"}
[:div.flex.flex-col.items-center.gap-8
[:div.text-center
[:h1.text-2xl.font-bold.text-gray-900 "Sign in to Integreat"]
[:p.mt-2.text-base.text-gray-500 "Use your Google account to continue"]]
[:div.m-4.overflow-auto.z-30.flex.center-items.justify-center.text-red-800.bg-red-50.dark:bg-gray-800.dark:text-red-400.border-red-300.rounded-lg.border.max-h-96
{:x-show "showError"
"x-transition:enter" "transition duration-300"
"x-transition:enter-start" "opacity-0"
"x-transition:enter-end" "opacity-100"}
[:a {:href (login-url (get (:query-params request) "redirect-to"))
:class "w-full max-w-xs flex items-center justify-center gap-3 px-6 py-3.5 text-base font-semibold rounded-xl border-2 border-gray-200 text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-300 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400 transition-all duration-150"}
svg/google
"Sign in with Google"]]
[:div {:class "p-4 mb-4 text-lg w-full" :role "alert"}
[:div.inline-block.w-8.h-8.mr-2 svg/alert]
[:span.font-medium "Oh, drat! An unexpected error has occurred."]
[:div.text-sm {:x-data (hx/json {"expandError" false})}
[:p "Integreat staff have been notified and are looking into it. "]
[:p "To see error details, " [:a.underline.cursor-pointer {"@click" "expandError=true"} "click here"] "."]
[:pre#error-details.text-xs {:x-show "expandError" :x-text "errorDetails"}]]]]]]
[:div.p-4.flex.flex-row.justify-center.items-center.h-screen
(com/card {:class "animate-slideUp"}
[:div.p-4
[:img {:src "/img/logo-big.png"}]
[:div
[:a.button.is-large.is-primary {:href (login-url (get (:query-params request) "redirect-to"))} "Login with Google"]]
"HELLO"])]]]])
[:p.mt-2.text-center.text-xs.text-gray-400
"By signing in, you agree to our "
[:a.underline.hover:text-gray-600 {:href "/terms"} "Terms of Service"]
" and "
[:a.underline.hover:text-gray-600 {:href "/privacy"} "Privacy Policy"]]]]]])
(defn login [request]
(base-page
request
(page-contents request)
"Dashboard"))
(login-page (page-contents request)))

View File

@@ -0,0 +1,54 @@
(ns auto-ap.ssr.components.wizard-trial.core
(:require
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.utils :refer [html-response main-transformer modal-response]]
[malli.core :as mc]
[malli.error :as me]))
(defn render-step
"Renders a single step form.
step-config is a map with:
- :key - step keyword
- :render - function taking {:keys [step-data errors request]} returning hiccup
- :submit-route - string URL for form POST"
[{:keys [wizard-id step-config request errors]}]
(let [{:keys [key render submit-route]} step-config
wizard (ws/get-wizard wizard-id)
step-data (get-in wizard [:data key] {})]
[:form {:hx-post submit-route
:hx-target "this"}
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
[:input {:type "hidden" :name "step-key" :value (name key)}]
(render {:step-data step-data :errors errors :request request})]))
(defn handle-submit
"Handles step submission.
Validates step data against schema.
If valid: saves to session and calls done-fn.
If invalid: re-renders step with errors."
[step-config request]
(let [{:keys [form-params]} request
wizard-id (get form-params "wizard-id")
step-key (keyword (get form-params "step-key"))
fields (:fields step-config)
step-data (reduce (fn [acc field]
(if-let [v (get form-params (name field))]
(assoc acc field v)
acc))
{}
fields)
schema (:schema step-config)
decoded (mc/decode schema step-data main-transformer)
valid? (mc/validate schema decoded)]
(if valid?
(do
(ws/update-step! wizard-id step-key decoded)
(let [all-data (ws/get-all-data wizard-id)]
(ws/destroy! wizard-id)
((:done-fn step-config) all-data request)))
(let [errors (me/humanize (mc/explain schema decoded))]
(modal-response
(render-step {:wizard-id wizard-id
:step-config step-config
:request request
:errors errors}))))))

View File

@@ -0,0 +1,35 @@
(ns auto-ap.ssr.components.wizard-trial.state)
(defonce ^:private store (atom {}))
(defn create!
"Creates new wizard session with initial data. Returns wizard-id string."
[initial-data]
(let [id (str (java.util.UUID/randomUUID))]
(swap! store assoc id {:data initial-data
:created-at (java.util.Date.)})
id))
(defn get-wizard
"Retrieves wizard data by id. Returns nil if not found."
[id]
(get @store id))
(defn update-step!
"Merges step data into wizard session under step-key."
[id step-key step-data]
(swap! store update-in [id :data step-key] merge step-data))
(defn get-all-data
"Returns merged data from all steps for final submission."
[id]
(when-let [wizard (get-wizard id)]
(let [data (:data wizard)]
(apply merge
(into {} (remove (comp map? val) data))
(filter map? (vals data))))))
(defn destroy!
"Removes wizard session."
[id]
(swap! store dissoc id))

View File

@@ -83,11 +83,11 @@
[:line {:stroke "currentColor", :fill "none", :stroke-linejoin "round", :y1 "16.22", :stroke-linecap "round", :stroke-width "1.5px", :x1 "16.221", :y2 "23.25", :x2 "23.25"}]])
(def moon
[:svg {:id "theme-toggle-dark-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:svg {:id "theme-toggle-dark-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:d "M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"}]])
(def sun
[:svg {:id "theme-toggle-light-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:svg {:id "theme-toggle-light-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:d "M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z", :fill-rule "evenodd", :clip-rule "evenodd"}]])
(def home
@@ -157,23 +157,23 @@
[:defs]
[:title "navigation-next"]
[:path
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]
[:polyline
{:points "19 13.498 23 9.498 19 5.498",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
{:points "19 13.498 23 9.498 19 5.498",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]
[: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 "currentColor",
:stroke-linecap "round",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]])
(def play
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "-0.5 -0.5 24 24"}
@@ -187,26 +187,26 @@
[:defs]
[:title "pencil"]
[:rect
{:y "1.09",
:stroke "currentColor",
:transform "translate(11.889 -5.238) rotate(45)",
:fill "none",
{:y "1.09",
:stroke "currentColor",
:transform "translate(11.889 -5.238) rotate(45)",
:fill "none",
:stroke-linejoin "round",
:width "6",
:stroke-linecap "round",
:x "9.268",
:height "21.284"}]
:width "6",
:stroke-linecap "round",
:x "9.268",
:height "21.284"}]
[:polygon
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]
[:path
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]])
(def dollar-tag
@@ -231,15 +231,15 @@
[:path
{:d
"M5.5,11.5c-.275,0-.341.159-.146.354l6.292,6.293a.5.5,0,0,0,.709,0l6.311-6.275c.2-.193.13-.353-.145-.355L15.5,11.5V1.5a1,1,0,0,0-1-1h-5a1,1,0,0,0-1,1V11a.5.5,0,0,1-.5.5Z",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]
[:path
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
:fill "none",
:stroke "currentColor",
:stroke-linecap "round",
:stroke-linejoin "round"}]])
(def trash
@@ -522,3 +522,10 @@
[:path {:d "m12 16 0 3", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]
[:path {:d "M4.5 9.5h15s1 0 1 1v12s0 1 -1 1h-15s-1 0 -1 -1v-12s0 -1 1 -1", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]
[:path {:d "M6.5 6a5.5 5.5 0 0 1 11 0v3.5h-11Z", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]])
(def google
[:svg {:viewbox "0 0 24 24", :width "20", :height "20", :xmlns "http://www.w3.org/2000/svg"}
[:path {:fill "#4285F4" :d "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"}]
[:path {:fill "#34A853" :d "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"}]
[:path {:fill "#FBBC05" :d "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"}]
[:path {:fill "#EA4335" :d "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"}]])

View File

@@ -15,6 +15,7 @@
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.transaction.bulk-code :as bulk-code :refer [all-ids-not-locked]]
[auto-ap.ssr.transaction.bulk-code-trial :as bulk-code-trial]
[auto-ap.ssr.transaction.common :refer [bank-account-filter* fetch-ids
grid-page query-schema
wrap-status-from-source]]
@@ -53,7 +54,7 @@
all-selected
(:ids (fetch-ids (dc/db conn) (-> request
(assoc-in [:form-params :start] 0)
(assoc-in [:form-params :per-page] 250))))
(assoc-in [:form-params :per-page] 250))))
:else
selected)
all-ids (all-ids-not-locked ids)
@@ -101,16 +102,18 @@
(def key->handler
(merge edit/key->handler
bulk-code/key->handler
{::route/bulk-code-trial bulk-code-trial/open-trial
::route/bulk-code-trial-submit bulk-code-trial/submit-trial}
(apply-middleware-to-all-handlers
{::route/page page
{::route/page page
::route/approved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/approved))
::route/unapproved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/unapproved))
::route/requires-feedback-page (-> page (wrap-implied-route-param :status :transaction-approval-status/requires-feedback))
::route/table table
::route/csv csv
::route/table table
::route/csv csv
::route/bank-account-filter bank-account-filter
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))}
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)

View File

@@ -0,0 +1,172 @@
(ns auto-ap.ssr.transaction.bulk-code-trial
(:require
[auto-ap.datomic :refer [conn pull-attr pull-many]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-trial.core :as wt]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead* location-select*]]
[auto-ap.ssr.utils :refer [html-response modal-response percentage]]
[auto-ap.ssr.svg :as svg]
[bidi.bidi :as bidi]
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]))
(def bulk-code-schema
(mc/schema
[:map
[:vendor {:optional true} [:maybe int?]]
[:approval-status {:optional true} [:maybe keyword?]]
[:accounts {:optional true}
[:vector {:coerce? true}
[:map
[:account int?]
[:location :string]
[:percentage percentage]]]]]))
(defn- account-row
"Renders a single account row with typeahead, location select, percentage, and delete button."
[index {:keys [account location percentage]} errors request]
(let [account-name (str "accounts[" index "][account]")
location-name (str "accounts[" index "][location]")
percentage-name (str "accounts[" index "][percentage]")
row-errors (get errors index)
client-id (-> request :clients first :db/id)
account-location (try
(when (nat-int? account)
(:account/location (dc/pull (dc/db conn) '[:account/location] account)))
(catch Exception e nil))
client-locations (try
(pull-attr (dc/db conn) :client/locations client-id)
(catch Exception e nil))]
[:tr
[:td (com/validated-field
{:errors (get row-errors :account)}
(account-typeahead* {:value account
:client-id client-id
:name account-name}))]
[:td (com/validated-field
{:errors (get row-errors :location)}
(location-select* {:name location-name
:account-location account-location
:client-locations client-locations
:value location}))]
[:td (com/validated-field
{:errors (get row-errors :percentage)}
(com/money-input {:name percentage-name
:value (some-> percentage (* 100) long)
:class "w-16"}))]
[:td (com/a-icon-button {"@click.prevent.stop" "this.closest('tr').remove()"}
svg/x)]]))
(defn render-bulk-code-form
"Renders the bulk code form inside a modal card structure.
Takes {:keys [step-data errors request]}"
[{:keys [step-data errors request]}]
(let [vendor (get step-data :vendor)
approval-status (get step-data :approval-status)
accounts (get step-data :accounts
[{:account nil :location "Shared" :percentage 0.5}
{:account nil :location "Shared" :percentage 0.5}
{:account nil :location "" :percentage nil}])
selected-ids [] ; Would come from request in real implementation
all-ids []]
(com/modal-card-advanced
{:class "md:w-[750px] md:h-[600px] w-full h-full"}
(com/modal-header {}
[:div.p-2 "Bulk editing " (count all-ids) " transactions"])
(com/modal-body {}
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div
(com/validated-field
{:label "Vendor"
:errors (get errors :vendor)}
(com/typeahead {:name "vendor"
:placeholder "Search for vendor..."
:url (bidi/path-for auto-ap.ssr-routes/only-routes :vendor-search)
:value vendor
:content-fn (fn [c]
(try
(pull-attr (dc/db conn) :vendor/name c)
(catch Exception e
"Vendor")))}))]
;; Approval status field
[:div
(com/validated-field
{:label "Status"
:errors (get errors :approval-status)}
(com/select {:name "approval-status"
:value (some-> approval-status name)
:allow-blank? true
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]}))]]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
(com/validated-field
{:errors (get errors :accounts)}
[:table.w-full.text-sm.text-left
[:thead
[:tr
[:th "Account"]
[:th {:class "w-32"} "Location"]
[:th {:class "w-16"} "%"]
[:th {:class "w-16"}]]]
[:tbody
(map-indexed
(fn [idx account]
(account-row idx account (get errors :accounts) request))
accounts)]])]
;; Add new account button
[:div
(com/button {:color :secondary
:type "button"
:class "mt-2"
"@click" (str "
const tbody = this.closest('form').querySelector('tbody');
const newRow = document.createElement('tr');
newRow.innerHTML = `
<td><input type='text' name='accounts[" (count accounts) "][account]' placeholder='Account ID' class='w-full'></td>
<td><input type='text' name='accounts[" (count accounts) "][location]' value='Shared' class='w-full'></td>
<td><input type='number' name='accounts[" (count accounts) "][percentage]' class='w-16'></td>
<td><button type='button' onclick='this.closest(\"tr\").remove()'>×</button></td>
`;
tbody.appendChild(newRow);
")}
"New account")]])
(com/modal-footer {}
[:div.flex.justify-end
[:div.flex.items-baseline.gap-x-4
(com/form-errors {:errors (seq errors)})
(com/button {:color :primary :type "submit" :class "w-32"} "Save")]]))))
(def trial-step-config
{:key :bulk-code
:schema bulk-code-schema
:fields [:vendor :approval-status :accounts]
:render render-bulk-code-form
:submit-route "/transaction/bulk-code-trial"
:done-fn (fn [data request]
(modal-response
(com/success-modal {:title "Transactions Coded (Trial)"}
[:p "This was a trial run. No transactions were actually modified."])
:headers {"hx-trigger" "refreshTable"}))})
(defn open-trial [request]
(let [wizard-id (ws/create! {})]
(modal-response
(wt/render-step {:wizard-id wizard-id
:step-config trial-step-config
:request request}))))
(defn submit-trial [request]
(wt/handle-submit trial-step-config request))

View File

@@ -440,6 +440,12 @@
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code")
(com/button {:color :secondary
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code-trial)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code (Trial)")
(com/button {:color :primary
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"

View File

@@ -1,5 +1,6 @@
(ns auto-ap.ssr.transaction.edit
(:require
[auto-ap.cursor :as cursor]
[auto-ap.datomic
:refer [audit-transact conn pull-attr pull-ref]]
[auto-ap.datomic.accounts :as d-accounts]
@@ -179,6 +180,82 @@
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})])
(defn simple-mode-fields*
"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.
Caller must establish Alpine x-data with simpleAccountId in scope."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
client-id (or (-> request :entity :transaction/client :db/id)
(:transaction/client snapshot))
existing-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))
account-val (let [av (:transaction-account/account existing-row)]
(if (map? av) (:db/id av) av))
location-val (or (:transaction-account/location existing-row) "Shared")
account-id (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val))
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))]
[:div
(fc/with-field :transaction/accounts
(fc/with-cursor (let [cur fc/*current*]
(if (sequential? @cur)
(nth cur 0 nil)
(auto_ap.cursor.MapCursor. {} (cursor/state cur) (conj (cursor/path cur) 0))))
[:span
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value row-id}))
[:div.flex.gap-2.mt-2
(fc/with-field :transaction-account/account
(com/validated-field
{:label "Account"
:errors (fc/field-errors)}
[:div.w-72
(account-typeahead* {:value account-val
:client-id client-id
:name (fc/field-name)
:x-model "simpleAccountId"})]))
(fc/with-field :transaction-account/location
(com/validated-field
{:label "Location"
:errors (fc/field-errors)
:x-hx-val:account-id "simpleAccountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(location-select*
{:name (fc/field-name)
:account-location (:account/location account-id)
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value location-val})))
(fc/with-field :transaction-account/amount
(com/hidden {:name (fc/field-name)
:value total}))]]))
[:div.mt-1
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to advanced mode"]]]))
(defn- manual-mode-initial
"Returns :simple or :advanced based on existing account row count."
[snapshot]
(let [rows (seq (:transaction/accounts snapshot))]
(if (and rows (> (count rows) 1))
:advanced
:simple)))
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
(com/data-grid-row
(-> {:class "account-row"
@@ -420,6 +497,55 @@
(format "$%,.2f" total))
(com/data-grid-cell {})))))
(defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab.
mode is :simple or :advanced.
In simple mode, establishes Alpine x-data with simpleAccountId in scope.
Must be called within a fc/start-form + fc/with-field :step-params context."
[mode request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
all-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
row-count (count all-accounts)]
[:div#manual-coding-section
(com/hidden {:name "mode" :value (name mode)})
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
(com/validated-field
{:label "Vendor" :errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
(if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId
(let [av (-> (first all-accounts) :transaction-account/account)]
(if (map? av) (:db/id av) av))})}
(simple-mode-fields* request)]
[:div
(when (<= row-count 1)
[:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to simple mode"]])
(fc/with-field :transaction/accounts
(com/validated-field
{:errors (fc/field-errors)}
[:div#account-grid-body
(account-grid-body* request)]))])]))
(defn toggle-amount-mode [request]
(let [snapshot (-> request :multi-form-state :snapshot)
old-mode (or (:amount-mode snapshot) "$")
@@ -815,7 +941,6 @@
":disabled" "!canChange"}
"Manual"))]
[: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"}
(payment-matches-view request)]
[: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"}
(unpaid-invoices-view request)]
@@ -825,27 +950,7 @@
(transaction-rules-view request)]
[: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 {}
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#account-grid-body"
:hx-swap "outerHTML"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
(com/validated-field
{:label "Vendor"
:errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
;; Memo field
;; Approval status field
(manual-coding-section* (manual-mode-initial snapshot) request)
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
@@ -867,12 +972,7 @@
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
:class "rounded-r-lg"}
"Client Review")]])))
(fc/with-field :transaction/accounts
(com/validated-field
{:errors (fc/field-errors)}
[:div#account-grid-body
(account-grid-body* request)]))]]]])
"Client Review")]])))]]]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Done"))
@@ -1329,9 +1429,11 @@
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params) "simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params) (:transaction/vendor snapshot))
vendor-id (or (:transaction/vendor step-params)
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
@@ -1351,8 +1453,39 @@
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)]
(html-response
[:div#account-grid-body
(render-account-grid-body render-request)])))
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* mode render-request))))))
(defn edit-wizard-toggle-mode-handler [request]
(let [step-params (-> request :multi-form-state :step-params)
snapshot (-> request :multi-form-state :snapshot)
current-mode (keyword (or (:mode step-params) "simple"))
target-mode (if (= current-mode :simple) :advanced :simple)
;; When switching simple→advanced, promote simple-mode values into accounts
render-request
(if (and (= target-mode :advanced)
(= current-mode :simple))
;; carry the simple-mode single row into snapshot so the table shows it
(let [accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] [])))))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* target-mode render-request))))))
(def key->handler
(apply-middleware-to-all-handlers
@@ -1395,6 +1528,10 @@
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/edit-wizard-new-account (->
(add-new-entity-handler [:step-params :transaction/accounts]
(fn render [cursor request]

32
src/clj/dev_mcp.clj Normal file
View File

@@ -0,0 +1,32 @@
(ns dev-mcp
"Leiningen task: start nREPL + web server on random ports."
(:require [clojure.java.io :as io]
[nrepl.server :as nrepl])
(:import [java.net ServerSocket]))
(defn available-port []
"Pick a random available TCP port."
(with-open [s (ServerSocket. 0)]
(.getLocalPort s)))
(defn- mcp-repl-task [& _args]
"Start nREPL server and HTTP server on random ports.
Writes ports to nrepl-port and .http-port files.
Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
(let [nrepl-port (available-port)
http-port (available-port)]
(spit "nrepl-port" (str nrepl-port))
(spit ".http-port" (str http-port))
(println (format "nREPL port: %d (nrepl-port)" nrepl-port))
(println (format "HTTP port: %d (.http-port)" http-port))
(nrepl/start-server :port nrepl-port)
(require 'user)
((resolve 'user/start-dev) http-port)
(println "Ready.")
@(promise)))
(defn -main
"Entry point for: lein trampoline run -m dev-mcp"
[& args]
(apply mcp-repl-task args))

View File

@@ -84,24 +84,24 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn load-accounts [conn]
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
:db/id])]
:in ['$]
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
:db/id])]
:in ['$]
:where ['[?e :account/name]]}
(dc/db conn))))
also-merge-txes (fn [also-merge old-account-id]
(if old-account-id
(let [[sunset-account]
(first (dc/q {:find ['?a]
:in ['$ '?ac]
(first (dc/q {:find ['?a]
:in ['$ '?ac]
:where ['[?a :account/numeric-code ?ac]]}
(dc/db conn) also-merge))]
(into (mapv
(fn [[entity id _]]
[:db/add entity id old-account-id])
(dc/q {:find ['?e '?id '?a]
:in ['$ '?ac]
(dc/q {:find ['?e '?id '?a]
:in ['$ '?ac]
:where ['[?a :account/numeric-code ?ac]
'[?e ?at ?a]
'[?at :db/ident ?id]]}
@@ -112,7 +112,7 @@
txes (transduce
(comp
(map (fn ->map [r]
(map (fn ->map [r]
(into {} (map vector header r))))
(map (fn parse-map [r]
{:old-account-id (:db/id (code->existing-account
@@ -160,8 +160,8 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-bad-accounts []
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
:in ['$]
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
:in ['$]
:where ['[?e :account/numeric-code ?z]
'[(<= ?z 9999)]
'[?x ?a ?e]]}
@@ -177,8 +177,8 @@
[:db/retractEntity old-account-id])))
conj
[]
(dc/q {:find ['?e]
:in ['$]
(dc/q {:find ['?e]
:in ['$]
:where ['[?e :account/numeric-code ?z]
'[(<= ?z 9999)]]}
(dc/db conn)))))
@@ -192,27 +192,27 @@
(fn [acc [e z]]
(update acc z conj e))
{}
(dc/q {:find ['?e '?z]
:in ['$]
(dc/q {:find ['?e '?z]
:in ['$]
:where ['[?e :account/numeric-code ?z]]}
(dc/db conn)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn customize-accounts [customer filename]
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
[client-id] (first (dc/q (-> {:find ['?e]
:in ['$ '?z]
[client-id] (first (dc/q (-> {:find ['?e]
:in ['$ '?z]
:where [['?e :client/code '?z]]}
(dc/db conn) customer)))
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
{:account/applicability [:db/ident]}
:db/id])]
:in ['$]
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
{:account/applicability [:db/ident]}
:db/id])]
:in ['$]
:where ['[?e :account/name]]}
(dc/db conn))))
existing-account-overrides (dc/q {:find ['?e]
:in ['$ '?client-id]
existing-account-overrides (dc/q {:find ['?e]
:in ['$ '?client-id]
:where [['?e :account-client-override/client '?client-id]]}
(dc/db conn) client-id)
@@ -276,8 +276,8 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn fix-transactions-without-locations [client-code location]
(->>
(dc/q {:find ['(pull ?e [*])]
:in ['$ '?client-code]
(dc/q {:find ['(pull ?e [*])]
:in ['$ '?client-code]
:where ['[?e :transaction/accounts ?ta]
'[?e :transaction/matched-rule]
'[?e :transaction/approval-status :transaction-approval-status/approved]
@@ -297,8 +297,8 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn entity-history [i]
(vec (sort-by first (dc/q
{:find ['?tx '?z '?v]
:in ['?i '$]
{:find ['?tx '?z '?v]
:in ['?i '$]
:where ['[?i ?a ?v ?tx ?ad]
'[?a :db/ident ?z]
'[(= ?ad true)]]}
@@ -307,8 +307,8 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn entity-history-with-revert [i]
(vec (sort-by first (dc/q
{:find ['?tx '?z '?v '?ad]
:in ['?i '$]
{:find ['?tx '?z '?v '?ad]
:in ['?i '$]
:where ['[?i ?a ?v ?tx ?ad]
'[?a :db/ident ?z]]}
i (dc/history (dc/db conn))))))
@@ -347,15 +347,18 @@
(hawk.core/watch! [{:paths ["src/" "test/"]
:handler auto-reset-handler}]))
(defn start-http []
(defn start-http [& [http-port]]
(when http-port
(alter-var-root #'auto-ap.server/*http-port-override* (constantly http-port)))
(mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty})))
(defn start-dev []
(defn start-dev [& [http-port]]
(set-refresh-dirs "src")
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
(clojure.tools.namespace.repl/disable-reload! (find-ns 'dev-mcp))
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
(start-db)
(start-http)
(start-http http-port)
(auto-reset))
#_(defn start-search []
@@ -376,18 +379,18 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-queries [words]
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
:prefix (str "queries/"))
concurrent 30
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
:prefix (str "queries/"))
concurrent 30
output-chan (async/chan)]
(async/pipeline-blocking concurrent
output-chan
(comp
(map #(do
[(:key %)
(str (slurp (:object-content (s3/get-object
:bucket-name (:data-bucket env)
:key (:key %)))))]))
(str (slurp (:object-content (s3/get-object
:bucket-name (:data-bucket env)
:key (:key %)))))]))
(filter #(->> words
(every? (fn [w] (str/includes? (second %) w)))))
@@ -401,9 +404,9 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn upsert-invoice-amounts [tsv]
(let [data (with-open [reader (io/reader (char-array tsv))]
(doall (csv/read-csv reader :separator \tab)))
db (dc/db conn)
(let [data (with-open [reader (io/reader (char-array tsv))]
(doall (csv/read-csv reader :separator \tab)))
db (dc/db conn)
i->invoice-id (fn [i]
(try (Long/parseLong i)
(catch Exception e
@@ -456,7 +459,7 @@
:when current-total]
[(when (not (auto-ap.utils/dollars= current-total target-total))
{:db/id invoice-id
{:db/id invoice-id
:invoice/total target-total})
(when new-account?
@@ -521,7 +524,7 @@
(let [client-location (ffirst (d/q '[:find ?l :in $ ?c :where [?c :client/locations ?l]] (dc/db conn) [:client/code client-code]))]
(clojure.data.csv/write-csv
*out*
(for [n (range n)
(for [n (range n)
:let [v (rand-nth (map first (d/q '[:find ?vn :where [_ :vendor/name ?vn]] (dc/db conn))))
[{a-1 :account/numeric-code a-1-location :account/location}
{a-2 :account/numeric-code a-2-location :account/location}] (->> (d/q '[:find (pull ?a [:account/numeric-code :account/location]) :where [?a :account/numeric-code]]
@@ -534,8 +537,8 @@
(t/minus (t/days (rand-int 60)))
(atime/unparse atime/normal-date))
id (rand-int 100000)]
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
a)
:separator \tab))))
@@ -547,7 +550,7 @@
(let [bank-accounts (map first (d/q '[:find ?bac :in $ ?c :where [?c :client/bank-accounts ?b] [?b :bank-account/code ?bac]] (dc/db conn) [:client/code client-code]))]
(clojure.data.csv/write-csv
*out*
(for [n (range n)
(for [n (range n)
:let [amount (rand-int 2000)
d (-> (t/now)
(t/minus (t/days (rand-int 60)))
@@ -563,7 +566,7 @@
:in $
:where [?i :invoice/invoice-number]
(not [?i :invoice/status :invoice-status/voided])]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -576,7 +579,7 @@
:in $
:where [?i :payment/date]
(not [?i :payment/status :payment-status/voided])]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -589,7 +592,7 @@
:in $
:where [?i :transaction/description-original]
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -600,7 +603,7 @@
(doseq [batch (->> (dc/qseq {:query '[:find ?i
:in $
:where [?i :journal-entry/date]]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")

View File

@@ -1,16 +1,18 @@
(ns auto-ap.routes.transactions)
(def routes {"" {:get ::page
:put ::edit-wizard-navigate
"/unapproved" ::unapproved-page
"/requires-feedback" ::requires-feedback-page
"/approved" ::approved-page
"/bulk-delete" ::bulk-delete
"/bulk-suppress" ::bulk-suppress
"/bulk-code" {:get ::bulk-code
:put ::bulk-code-submit
"/new-account" ::bulk-code-new-account
"/vendor-changed" ::bulk-code-vendor-changed}}
(def routes {"" {:get ::page
:put ::edit-wizard-navigate
"/unapproved" ::unapproved-page
"/requires-feedback" ::requires-feedback-page
"/approved" ::approved-page
"/bulk-delete" ::bulk-delete
"/bulk-suppress" ::bulk-suppress
"/bulk-code" {:get ::bulk-code
:put ::bulk-code-submit
"/new-account" ::bulk-code-new-account
"/vendor-changed" ::bulk-code-vendor-changed}
"/bulk-code-trial" {:get ::bulk-code-trial
:post ::bulk-code-trial-submit}}
"/new" {:get ::new
:post ::new-submit
"/location-select" ::location-select
@@ -22,9 +24,9 @@
"/parse" ::external-import-parse
"/import" ::external-import-import}
"/table" ::table
"/csv" ::csv
"/bank-account-filter" ::bank-account-filter
"/table" ::table
"/csv" ::csv
"/bank-account-filter" ::bank-account-filter
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
"/edit-submit" ::edit-submit
@@ -34,6 +36,7 @@
"/account-balance" ::account-balance
"/toggle-amount-mode" ::toggle-amount-mode
"/edit-wizard-new-account" ::edit-wizard-new-account
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
"/match-payment" ::link-payment
"/match-autopay-invoices" ::link-autopay-invoices
"/match-unpaid-invoices" ::link-unpaid-invoices

View File

@@ -0,0 +1,114 @@
(ns auto-ap.ssr.components.wizard-trial.core-test
(:require
[auto-ap.ssr.components.wizard-trial.core :as sut]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[clojure.test :refer [deftest is testing]]
[hiccup.core :as hiccup]))
(deftest render-step-test
(testing "render-step produces a form with hidden wizard-id and step-key inputs"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:render (fn [{:keys [step-data errors request]}]
[:div (str "data: " step-data) (when errors [:span.error (str errors)])])
:submit-route "/test-submit"}
result (sut/render-step {:wizard-id wizard-id
:step-config step-config
:request {}})]
(is (= :form (first result)))
(let [attrs (second result)
children (drop 2 result)]
(is (= "/test-submit" (:hx-post attrs)))
(is (= "this" (:hx-target attrs)))
(let [html (hiccup/html result)]
(is (re-find #"name=\"wizard-id\"" html))
(is (re-find #"value=\"" html))
(is (re-find #"name=\"step-key\"" html))
(is (re-find #"value=\"test-step\"" html))))))
(testing "render-step passes step-data, errors, and request to the render function"
(let [wizard-id (ws/create! {:test-step {:field "value"}})
captured (atom nil)
step-config {:key :test-step
:render (fn [args] (reset! captured args) [:div "rendered"])
:submit-route "/test-submit"}
_ (sut/render-step {:wizard-id wizard-id
:step-config step-config
:request {:client-id 123}
:errors {:field ["is invalid"]}})
{:keys [step-data errors request]} @captured]
(is (= {:field "value"} step-data))
(is (= {:field ["is invalid"]} errors))
(is (= {:client-id 123} request)))))
(deftest handle-submit-test
(testing "handle-submit with valid data saves step and calls done-fn"
(let [done-result (atom nil)
wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [_] [:div "rendered"])
:submit-route "/test-submit"
:done-fn (fn [data request]
(reset! done-result {:data data :request request})
{:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"name" "Alice"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (= "done" (:body response)))
(is (= "Alice" (get-in @done-result [:data :name])))
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit")))
(testing "handle-submit with invalid data re-renders step with errors"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [{:keys [errors]}]
[:div (when errors [:span.error (str errors)])])
:submit-route "/test-submit"
:done-fn (fn [_ _] {:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"name" ""}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (string? (:body response)))
(is (re-find #"error" (:body response)) "Response body should contain error markup")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should still exist after failed validation")))
(testing "handle-submit with missing required field shows validation error"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [{:keys [errors]}]
[:div (when errors [:span.error (str errors)])])
:submit-route "/test-submit"
:done-fn (fn [_ _] {:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response body should contain error markup for missing field")))
(testing "handle-submit decodes step data using main-transformer"
(let [done-result (atom nil)
wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:count int?]]
:fields [:count]
:render (fn [_] [:div "rendered"])
:submit-route "/test-submit"
:done-fn (fn [data _]
(reset! done-result data)
{:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"count" "42"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (= 42 (:count @done-result)) "String count should be decoded to integer"))))

View File

@@ -0,0 +1,76 @@
(ns auto-ap.ssr.components.wizard-trial.state-test
(:require
[auto-ap.ssr.components.wizard-trial.state :as sut]
[clojure.test :refer [deftest is testing]]))
(deftest create-and-get-wizard-test
(testing "Session creation returns a non-nil wizard-id"
(let [wizard-id (sut/create! {:foo "bar"})]
(is (string? wizard-id))
(is (seq wizard-id))))
(testing "Session retrieval returns the stored data"
(let [wizard-id (sut/create! {:foo "bar"})
wizard (sut/get-wizard wizard-id)]
(is (map? wizard))
(is (= {:foo "bar"} (:data wizard)))
(is (inst? (:created-at wizard)))))
(testing "Session retrieval returns nil for unknown id"
(is (nil? (sut/get-wizard "non-existent-id")))))
(deftest update-step-test
(testing "update-step! merges data into the specified step key"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a"} (get-in wizard [:data :step1])))))
(testing "update-step! merges without overwriting other step keys"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
_ (sut/update-step! wizard-id :step2 {:field-b "b"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a"} (get-in wizard [:data :step1])))
(is (= {:field-b "b"} (get-in wizard [:data :step2])))))
(testing "update-step! merges within the same step key"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
_ (sut/update-step! wizard-id :step1 {:field-b "b"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a" :field-b "b"} (get-in wizard [:data :step1]))))))
(deftest destroy-test
(testing "destroy! removes the wizard session"
(let [wizard-id (sut/create! {:foo "bar"})
_ (sut/destroy! wizard-id)]
(is (nil? (sut/get-wizard wizard-id)))))
(testing "destroy! is a no-op for unknown id"
(sut/destroy! "non-existent-id")
(is (nil? (sut/get-wizard "non-existent-id")))))
(deftest get-all-data-test
(testing "get-all-data merges non-map values and map values from all steps"
(let [wizard-id (sut/create! {:client-id 123})
_ (sut/update-step! wizard-id :step1 {:vendor 456})
_ (sut/update-step! wizard-id :step2 {:accounts [{:account 1}]})
all-data (sut/get-all-data wizard-id)]
(is (= {:client-id 123 :vendor 456 :accounts [{:account 1}]} all-data))))
(testing "get-all-data returns nil for unknown id"
(is (nil? (sut/get-all-data "non-existent-id")))))
(deftest session-exists-test
(testing "Session exists after creation"
(let [wizard-id (sut/create! {})]
(is (some? (sut/get-wizard wizard-id)))))
(testing "Session does not exist after destruction"
(let [wizard-id (sut/create! {})
_ (sut/destroy! wizard-id)]
(is (nil? (sut/get-wizard wizard-id)))))
(testing "Session does not exist for random id"
(is (nil? (sut/get-wizard (str (java.util.UUID/randomUUID)))))))

View File

@@ -0,0 +1,104 @@
(ns auto-ap.ssr.transaction.bulk-code-trial-test
(:require
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.transaction.bulk-code-trial :as sut]
[clojure.test :refer [deftest is testing use-fixtures]]
[mount.core :as mount]))
(use-fixtures :each
(fn [test-fn]
(mount/start #'auto-ap.datomic/conn)
(test-fn)
(mount/stop #'auto-ap.datomic/conn)))
(deftest open-trial-test
(testing "open-trial returns modal-response with a form containing expected fields"
(let [response (sut/open-trial {})]
(is (= 200 (:status response)))
(is (= "text/html" (get-in response [:headers "Content-Type"])))
(let [body (:body response)]
(is (string? body))
(is (re-find #"modal-card" body) "Should contain modal card structure")
(is (re-find #"Bulk editing" body) "Should show header with transaction count")
(is (re-find #"Vendor" body) "Form should contain Vendor label")
(is (re-find #"Status" body) "Form should contain Status label")
(is (re-find #"Expense Accounts" body) "Form should contain Expense Accounts heading")
(is (re-find #"Account" body) "Form should contain Account column header")
(is (re-find #"Location" body) "Form should contain Location column header")
(is (re-find #"%" body) "Form should contain percentage column header")
(is (re-find #"Save" body) "Form should contain Save button")
(is (re-find #"New account" body) "Form should contain New account button")
(is (re-find #"name=\"wizard-id\"" body) "Form should contain hidden wizard-id input")
(is (re-find #"name=\"step-key\"" body) "Form should contain hidden step-key input")))))
(deftest submit-trial-valid-test
(testing "submit-trial with valid data returns success response and destroys session"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" "123"
"approval-status" "approved"
"accounts[0][account]" "1"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "50"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"Transactions Coded" (:body response)) "Response should indicate success")
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit"))))
(deftest submit-trial-invalid-test
(testing "submit-trial with invalid vendor id shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" "not-a-number"
"approval-status" "approved"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid vendor")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with invalid account data shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"accounts[0][account]" "not-a-number"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "50"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid account")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with percentage over 100% shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"accounts[0][account]" "1"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "150"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for percentage > 100%")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))
(deftest submit-trial-empty-test
(testing "submit-trial with empty form data shows validation errors"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (not (re-find #"Bulk code applied" (:body response))) "Empty form should not succeed")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with no account rows selected shows validation errors"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" ""
"approval-status" ""}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (not (re-find #"Bulk code applied" (:body response))) "Form with empty values should not succeed")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))

View File

@@ -0,0 +1,936 @@
(ns auto-ap.ssr.transaction.edit-simple-advanced-mode-test
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.integration.util :refer [wrap-setup]]
[auto-ap.solr]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.transaction.edit :refer [clientize-vendor
edit-vendor-changed-handler
edit-wizard-toggle-mode-handler
location-select*
manual-coding-section*
vendor-default-account]]
[clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as dc]
[hiccup.core :as hiccup]))
;; Private save-handler accessible via var reference
(def ^:private save-handler
#'auto-ap.ssr.transaction.edit/save-handler)
(use-fixtures :each wrap-setup)
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- tempid->id
"Resolve a string temp ID to its numeric Datomic entity ID after transact."
[result temp-id]
(get (:tempids result) temp-id))
;;; ---------------------------------------------------------------------------
;;; AC1-3: manual-mode-initial — mode selection on open
;;; ---------------------------------------------------------------------------
;; manual-mode-initial is private; access via var reference
(def ^:private manual-mode-initial
#'auto-ap.ssr.transaction.edit/manual-mode-initial)
(deftest manual-mode-initial-test
(testing "AC1: uncoded transaction (no accounts) opens in simple mode"
(is (= :simple (manual-mode-initial {:db/id 123})))
(is (= :simple (manual-mode-initial {:db/id 123 :transaction/accounts []}))))
(testing "AC2: single-account transaction opens in simple mode"
(is (= :simple (manual-mode-initial {:db/id 123
:transaction/accounts [{:transaction-account/account 456
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]}))))
(testing "AC3: multi-account (2+) transaction opens in advanced mode"
(is (= :advanced (manual-mode-initial {:db/id 123
:transaction/accounts [{:transaction-account/account 1}
{:transaction-account/account 2}]})))
(is (= :advanced (manual-mode-initial {:db/id 123
:transaction/accounts [{} {} {}]})))))
;;; ---------------------------------------------------------------------------
;;; AC4-5: edit-vendor-changed-handler — vendor selection in simple mode
;;; ---------------------------------------------------------------------------
(deftest edit-vendor-changed-simple-mode-test
(testing "AC4: vendor selection in simple mode populates account/location from vendor default"
;; Set up vendor with a default account
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Test Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "account-id"}
{:db/id "client-id"
:client/code "TESTCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts []}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts []})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; Response should contain the manual-coding-section div
(is (re-find #"manual-coding-section" body)
"Response should contain the manual-coding-section element")
;; The vendor's default account ID should appear in the rendered HTML
(is (re-find (re-pattern (str account-id)) body)
"Response should contain the vendor's default account ID")
;; The account's name should appear in the rendered HTML
(is (re-find #"Test Account" body)
"Response should contain the vendor's default account name")))
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Test Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "account-id"}
{:db/id "other-account-id"
:account/name "Other Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "TESTCL2"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id")
;; existing-accounts already set means vendor should NOT overwrite
existing-accounts [{:db/id "row-id"
:transaction-account/account other-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element")
;; The original account ID must still appear in the rendered HTML
(is (re-find (re-pattern (str other-account-id)) body)
"Response should contain the original (pre-existing) account ID")
;; The vendor's default account ID must NOT appear — it was not used
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body))
"Response should NOT contain the vendor's default account ID when existing account is set"))))
;;; ---------------------------------------------------------------------------
;;; AC6: save round-trip — manual mode saves vendor + account to DB
;;; ---------------------------------------------------------------------------
(deftest save-manual-round-trip-test
(testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Save Vendor"}
{:db/id "account-id"
:account/name "Save Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "SAVECL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
new-row-id (str (java.util.UUID/randomUUID))
snapshot {:db/id tx-id
:transaction/client client-id
:action :manual
:transaction/vendor vendor-id
:transaction/amount 100.0
:transaction/accounts [{:db/id new-row-id
:transaction-account/account account-id
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]}
request {:multi-form-state (mm/->MultiStepFormState snapshot [] snapshot)
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}
:identity {:user/role "admin"}}]
(with-redefs [auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
(save-handler request))
;; Verify the transaction was saved to DB with vendor and accounts
(let [saved (dc/pull (dc/db conn)
'[:db/id
{:transaction/vendor [:db/id]}
{:transaction/accounts [{:transaction-account/account [:db/id]}
:transaction-account/location
:transaction-account/amount]}]
tx-id)]
(is (= vendor-id (-> saved :transaction/vendor :db/id))
"Vendor should be saved on the transaction")
(is (= 1 (count (:transaction/accounts saved)))
"Exactly one account row should be saved")
(is (= account-id (-> saved :transaction/accounts first :transaction-account/account :db/id))
"The correct account should be saved")
;; "Shared" is spread to client-specific locations by maybe-spread-locations
(is (= "DT" (-> saved :transaction/accounts first :transaction-account/location))
"The location should be saved (Shared spreads to client locations)")
(is (= 100.0 (-> saved :transaction/accounts first :transaction-account/amount))
"The amount should be saved")))))
;;; ---------------------------------------------------------------------------
;;; AC7: simple mode account typeahead respects :account/default-allowance rules
;;; ---------------------------------------------------------------------------
(deftest simple-mode-account-typeahead-allowance-test
(testing "AC7: simple mode account typeahead URL includes purpose=transaction"
;; The account-typeahead* always passes purpose=transaction to the search URL,
;; and the schema validates accounts using check-allowance with :account/default-allowance.
;; We test the rendering side: the typeahead URL in simple-mode-fields* must contain purpose=transaction.
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "ALLOWCL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Allowed Account"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
one-account [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts one-account}
[]
{:mode "simple"
:transaction/accounts one-account})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
html (hiccup/html
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(manual-coding-section* :simple request))))]
(is (re-find #"purpose=transaction" html)
"Simple mode account typeahead URL must include purpose=transaction to enforce allowance rules"))))
;;; ---------------------------------------------------------------------------
;;; AC8: location dropdown — fixed location vs full client list
;;; ---------------------------------------------------------------------------
(deftest location-select-options-test
(testing "AC8: when account has a fixed location, dropdown shows only that location"
(let [html (hiccup/html
(location-select* {:name "location"
:account-location "HQ"
:client-locations ["DT" "NY"]
:value "HQ"}))]
(is (re-find #"HQ" html)
"Fixed account location 'HQ' should appear in dropdown")
(is (not (re-find #"DT" html))
"Client location 'DT' should NOT appear when account has fixed location")
(is (not (re-find #"Shared" html))
"'Shared' option should not appear when account has fixed location")))
(testing "AC8: when account has no fixed location, dropdown shows full client location list"
(let [html (hiccup/html
(location-select* {:name "location"
:account-location nil
:client-locations ["DT" "NY"]
:value "Shared"}))]
(is (re-find #"Shared" html)
"'Shared' option should appear when no fixed account location")
(is (re-find #"DT" html)
"Client location 'DT' should appear when no fixed account location")
(is (re-find #"NY" html)
"Client location 'NY' should appear when no fixed account location"))))
;;; ---------------------------------------------------------------------------
;;; AC9: simple → advanced toggle with pre-populated row
;;; ---------------------------------------------------------------------------
(deftest toggle-simple-to-advanced-test
(testing "AC9: clicking 'Switch to advanced mode' from simple mode re-renders in advanced with one row pre-populated"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "TOGGLECL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Toggle Account"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
simple-account [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts simple-account}
[]
{:mode "simple"
:transaction/accounts simple-account})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-wizard-toggle-mode-handler request)
body (:body response)]
(is (re-find #"manual-coding-section" body)
"Response body should contain the coding section element")
;; Advanced mode has Switch to simple mode link (when <=1 row)
(is (re-find #"Switch to simple mode" body)
"After toggling to advanced mode (from simple with 1 row), 'Switch to simple mode' should appear")
;; The account-grid-body section should be present in the advanced mode HTML
(is (re-find #"account-grid-body" body)
"Advanced mode response should contain the account-grid-body element")
;; The account ID from the simple-mode row should appear in the advanced table HTML
(is (re-find (re-pattern (str account-id)) body)
"Advanced mode response should contain the account ID from the simple-mode row")
;; The account name should appear in the advanced table HTML
(is (re-find #"Toggle Account" body)
"Advanced mode response should contain the account name from the simple-mode row"))))
;;; ---------------------------------------------------------------------------
;;; AC10: blank simple → advanced gives empty table
;;; ---------------------------------------------------------------------------
(deftest toggle-blank-simple-to-advanced-test
(testing "AC10: 'Switch to advanced mode' from blank simple mode gives advanced with empty table"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "BLNKTOGLCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
client-id (tempid->id result "client-id")
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts []}
[]
{:mode "simple"
:transaction/accounts []})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-wizard-toggle-mode-handler request)
body (:body response)]
;; Should render in advanced mode
(is (re-find #"account-grid-body" body)
"Blank simple → advanced should produce account-grid-body")
;; Should NOT have any account-row elements (empty grid)
(is (not (re-find #"account-row" body))
"Blank simple → advanced should have NO account rows")
;; Should show 'Switch to simple mode' because 0 rows <= 1
(is (re-find #"Switch to simple mode" body)
"Advanced mode with 0 rows should show 'Switch to simple mode' link"))))
;;; ---------------------------------------------------------------------------
;;; AC11-12: "Switch to simple mode" visibility based on row count
;;; ---------------------------------------------------------------------------
(deftest advanced-mode-switch-link-visibility-test
(testing "AC11: 'Switch to simple mode' link is visible in advanced mode with 0 or 1 rows"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "VISLINKCL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Visibility Account"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
zero-rows []
one-row [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
make-req (fn [accounts]
{:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts accounts}
[]
{:mode "advanced"
:transaction/accounts accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}})
html-zero-rows (hiccup/html
(fc/start-form (:multi-form-state (make-req zero-rows)) nil
(fc/with-field :step-params
(manual-coding-section* :advanced (make-req zero-rows)))))
html-one-row (hiccup/html
(fc/start-form (:multi-form-state (make-req one-row)) nil
(fc/with-field :step-params
(manual-coding-section* :advanced (make-req one-row)))))]
(is (re-find #"Switch to simple mode" html-zero-rows)
"In advanced mode with 0 rows, 'Switch to simple mode' should be visible")
(is (re-find #"Switch to simple mode" html-one-row)
"In advanced mode with 1 row, 'Switch to simple mode' should be visible")))
(testing "AC12: 'Switch to simple mode' link is absent in advanced mode with 2+ rows"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "TWOROWCL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Account1"
:account/type :account-type/expense}
{:db/id "account-id-2"
:account/name "Account2"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
account-id-2 (tempid->id result "account-id-2")
client-id (tempid->id result "client-id")
two-rows [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 50.0}
{:db/id "row-2"
:transaction-account/account account-id-2
:transaction-account/location "DT"
:transaction-account/amount 50.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts two-rows}
[]
{:mode "advanced"
:transaction/accounts two-rows})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
html (hiccup/html
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(manual-coding-section* :advanced request))))]
(is (not (re-find #"Switch to simple mode" html))
"In advanced mode with 2+ rows, 'Switch to simple mode' link should be absent"))))
;;; ---------------------------------------------------------------------------
;;; AC13: advanced → simple (1 row) re-renders in simple mode
;;; ---------------------------------------------------------------------------
(deftest toggle-advanced-to-simple-test
(testing "AC13: 'Switch to simple mode' from advanced (1 row) gives simple mode with that row's values"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "TOGSIMCL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Toggle Account"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
one-row [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts one-row}
[]
{:mode "advanced"
:transaction/accounts one-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-wizard-toggle-mode-handler request)
body (:body response)]
;; After toggling from advanced to simple, show "Switch to advanced mode" link
(is (re-find #"Switch to advanced mode" body)
"After toggling to simple mode, 'Switch to advanced mode' should be visible")
;; The row's account ID should appear in the simple mode form
(is (re-find (re-pattern (str account-id)) body)
"Simple mode should display the account from the advanced mode row"))))
;;; ---------------------------------------------------------------------------
;;; AC14: vendor change in advanced mode with no rows creates one row
;;; ---------------------------------------------------------------------------
(deftest edit-vendor-changed-advanced-mode-no-rows-test
(testing "AC14: vendor change in advanced mode with no rows creates one row with vendor's default account"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Advanced Vendor"}
{:db/id "account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "account-id"}
{:db/id "client-id"
:client/code "ADVVCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 200.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
;; Advanced mode with no accounts
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts []}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts []})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 200.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
(is (re-find #"manual-coding-section" body))
;; Should have account-grid-body (advanced mode)
(is (re-find #"account-grid-body" body)
"Advanced mode vendor change should show account-grid-body")
;; The vendor's default account ID should appear in the new row
(is (re-find (re-pattern (str account-id)) body)
"Advanced mode: vendor default account should appear in the newly created row")
(is (re-find #"Vendor Default Account" body)
"Advanced mode: vendor default account name should appear in the new row"))))
;;; ---------------------------------------------------------------------------
;;; AC15: vendor change in advanced mode with existing rows does NOT modify them
;;; ---------------------------------------------------------------------------
(deftest edit-vendor-changed-advanced-mode-existing-rows-test
(testing "AC15: vendor change in advanced mode with existing rows does NOT modify existing rows"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "New Vendor"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Existing Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVEXCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
existing-rows [{:db/id "row-1"
:transaction-account/account existing-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
;; Advanced mode with existing accounts — vendor change should NOT touch them
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-rows}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts existing-rows})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The original (existing) account should still be shown
(is (re-find (re-pattern (str existing-account-id)) body)
"Existing row account should remain unchanged when vendor changes in advanced mode")
;; The vendor's default account should NOT appear — existing rows are untouched
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default account should NOT replace existing rows in advanced mode"))))
;;; ---------------------------------------------------------------------------
;;; AC16: vendor-default-account uses clientized vendor logic
;;; ---------------------------------------------------------------------------
(deftest vendor-default-account-clientize-test
(testing "AC16: client-specific account override takes precedence over global vendor default"
(let [result @(dc/transact conn [{:db/id "global-account-id"
:account/name "Global Default Account"
:account/type :account-type/expense}
{:db/id "client-specific-account-id"
:account/name "Client Specific Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "CLACCTEST"
:client/locations ["DT"]}
{:db/id "vendor-id"
:vendor/name "Clientized Vendor"
:vendor/default-account "global-account-id"
:vendor/account-overrides [{:vendor-account-override/client "client-id"
:vendor-account-override/account "client-specific-account-id"}]}])
vendor-id (tempid->id result "vendor-id")
client-id (tempid->id result "client-id")
client-specific-account-id (tempid->id result "client-specific-account-id")
result-account (vendor-default-account vendor-id client-id)]
(is (some? result-account)
"Should return a default account")
(is (= client-specific-account-id (:db/id result-account))
"Client-specific account override should take precedence over global vendor default")))
(testing "AC16: falls back to global vendor default when no client override"
(let [result @(dc/transact conn [{:db/id "global-account-id"
:account/name "Global Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/name "Vendor No Override"
:vendor/default-account "global-account-id"}
{:db/id "client-id"
:client/code "NOOVCL"
:client/locations ["DT"]}])
vendor-id (tempid->id result "vendor-id")
client-id (tempid->id result "client-id")
global-account-id (tempid->id result "global-account-id")
result-account (vendor-default-account vendor-id client-id)]
(is (= global-account-id (:db/id result-account))
"Should use global vendor default when no client-specific override exists")))
(testing "AC16: clientize-vendor applies both terms and account overrides"
(let [vendor {:db/id "vendor-id"
:vendor/name "Test Vendor"
:vendor/default-account {:db/id "global-account-id"}
:vendor/terms "NET30"
:vendor/automatically-paid-when-due []
:vendor/terms-overrides [{:vendor-terms-override/client {:db/id "client-id"}
:vendor-terms-override/terms "NET60"}]
:vendor/account-overrides [{:vendor-account-override/client {:db/id "client-id"}
:vendor-account-override/account {:db/id "client-specific-account-id"}}]}
clientized (clientize-vendor vendor "client-id")]
(is (= "NET60" (:vendor/terms clientized))
"Terms override should be applied")
(is (= "client-specific-account-id" (-> clientized :vendor/default-account :db/id))
"Account override should be applied"))))
;;; ---------------------------------------------------------------------------
;;; AC17: approval status, memo, vendor fields present in both modes
;;; ---------------------------------------------------------------------------
(deftest fields-present-in-both-modes-test
;; NOTE: memo and approval-status fields are rendered by LinksStep/render-step
;; (outside of #manual-coding-section), so they cannot be tested at the
;; manual-coding-section* level. This test covers what manual-coding-section*
;; is directly responsible for.
(testing "AC17: key fields are present in both simple and advanced modes"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "FLDTESTCL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Fields Account"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
one-account [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
make-req (fn [mode accounts]
{:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts accounts}
[]
{:mode (name mode)
:transaction/accounts accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}})
html-simple (hiccup/html
(fc/start-form (:multi-form-state (make-req :simple one-account)) nil
(fc/with-field :step-params
(manual-coding-section* :simple (make-req :simple one-account)))))
html-advanced (hiccup/html
(fc/start-form (:multi-form-state (make-req :advanced one-account)) nil
(fc/with-field :step-params
(manual-coding-section* :advanced (make-req :advanced one-account)))))]
;; --- Vendor field assertions (both modes) ---
(is (re-find #"Vendor" html-simple)
"AC17: Vendor field label must appear in simple mode")
(is (re-find #"vendor/search" html-simple)
"AC17: Vendor typeahead/search URL must appear in simple mode")
(is (re-find #"transaction/vendor" html-simple)
"AC17: Vendor field name must appear in simple mode")
(is (re-find #"Vendor" html-advanced)
"AC17: Vendor field label must appear in advanced mode")
(is (re-find #"vendor/search" html-advanced)
"AC17: Vendor typeahead/search URL must appear in advanced mode")
(is (re-find #"transaction/vendor" html-advanced)
"AC17: Vendor field name must appear in advanced mode")
;; --- Hidden mode input (proves section is complete) ---
(is (re-find #"name=\"mode\".*value=\"simple\"" html-simple)
"AC17: hidden mode=simple input must be present in simple mode")
(is (re-find #"name=\"mode\".*value=\"advanced\"" html-advanced)
"AC17: hidden mode=advanced input must be present in advanced mode")
;; --- Simple-mode-specific fields: account typeahead, location select, toggle link ---
(is (re-find #"account/search" html-simple)
"AC17: account typeahead search URL must appear in simple mode")
(is (re-find #"transaction-account/location" html-simple)
"AC17: location select field name must appear in simple mode")
(is (re-find #"Switch to advanced mode" html-simple)
"AC17: toggle link to advanced mode must appear in simple mode")
;; --- Advanced-mode-specific: account-grid-body and toggle link ---
(is (re-find #"account-grid-body" html-advanced)
"AC17: #account-grid-body must be present in advanced mode")
(is (re-find #"Switch to simple mode" html-advanced)
"AC17: toggle link to simple mode must appear in advanced mode (<=1 row)"))))
;;; ---------------------------------------------------------------------------
;;; AC18: switching modes mid-edit then saving produces a valid transaction
;;; ---------------------------------------------------------------------------
(deftest switch-modes-then-save-test
(testing "AC18: switching simple→advanced→save produces a single valid transaction row in DB"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Switch Vendor"}
{:db/id "account-id"
:account/name "Switch Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "SWITCHCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
simple-row [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
;; Step 1: Start in simple mode
simple-request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts simple-row}
[]
{:mode "simple"
:transaction/accounts simple-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
;; Step 2: Toggle to advanced mode
toggle-response (edit-wizard-toggle-mode-handler simple-request)
;; After toggle, build a save request in advanced mode with one row
advanced-snapshot {:db/id tx-id
:transaction/client client-id
:action :manual
:transaction/vendor vendor-id
:transaction/amount 100.0
:transaction/accounts [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]}
save-request {:multi-form-state (mm/->MultiStepFormState advanced-snapshot [] advanced-snapshot)
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}
:identity {:user/role "admin"}}]
;; Verify the toggle worked
(is (re-find #"account-grid-body" (:body toggle-response))
"After toggle, response should be in advanced mode")
;; Now save
(with-redefs [auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
(save-handler save-request))
;; Verify exactly one account row was saved (no orphaned rows)
(let [saved (dc/pull (dc/db conn)
'[:db/id
{:transaction/accounts [{:transaction-account/account [:db/id]}
:transaction-account/location
:transaction-account/amount]}]
tx-id)]
(is (= 1 (count (:transaction/accounts saved)))
"AC18: Exactly one account row should be saved after mode-switch + save (no orphaned rows)")
(is (= account-id (-> saved :transaction/accounts first :transaction-account/account :db/id))
"AC18: The correct account should be saved")))))
;;; ---------------------------------------------------------------------------
;;; AC19: split transaction re-opens in advanced mode with all splits
;;; ---------------------------------------------------------------------------
(deftest split-transaction-reopens-advanced-test
(testing "AC19: split transaction (2+ accounts) re-opens in advanced mode with all splits intact"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "SPLITCL"
:client/locations ["DT"]}
{:db/id "account-id-1"
:account/name "Split Account 1"
:account/type :account-type/expense}
{:db/id "account-id-2"
:account/name "Split Account 2"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id-1 (tempid->id result "account-id-1")
account-id-2 (tempid->id result "account-id-2")
client-id (tempid->id result "client-id")
two-rows [{:db/id "row-1"
:transaction-account/account account-id-1
:transaction-account/location "DT"
:transaction-account/amount 50.0}
{:db/id "row-2"
:transaction-account/account account-id-2
:transaction-account/location "DT"
:transaction-account/amount 50.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts two-rows}
[]
{:mode "advanced"
:transaction/accounts two-rows})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
html (hiccup/html
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(manual-coding-section* :advanced request))))]
;; Should show both account IDs
(is (re-find (re-pattern (str account-id-1)) html)
"AC19: First split account should appear in advanced mode rendering")
(is (re-find (re-pattern (str account-id-2)) html)
"AC19: Second split account should appear in advanced mode rendering")
;; Should NOT show 'Switch to simple mode' with 2 rows
(is (not (re-find #"Switch to simple mode" html))
"AC19: Split transaction (2 rows) in advanced mode should NOT show 'Switch to simple mode'"))))
;;; ---------------------------------------------------------------------------
;;; AC20: single-account transaction re-opens in simple mode
;;; ---------------------------------------------------------------------------
(deftest single-account-reopens-simple-test
(testing "AC20: single-account transaction re-opens in simple mode with account/location pre-populated"
(let [result @(dc/transact conn [{:db/id "client-id"
:client/code "SINGCL"
:client/locations ["DT"]}
{:db/id "account-id"
:account/name "Single Account"
:account/type :account-type/expense}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
one-account [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts one-account}
[]
{:mode "simple"
:transaction/accounts one-account})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
html (hiccup/html
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(manual-coding-section* :simple request))))]
;; Simple mode should show the account typeahead pre-populated with the account
(is (re-find (re-pattern (str account-id)) html)
"AC20: Simple mode should show the account pre-populated")
;; Should show 'Switch to advanced mode' (confirming it's in simple mode)
(is (re-find #"Switch to advanced mode" html)
"AC20: Simple mode should show 'Switch to advanced mode' link")
;; Should NOT show 'Switch to simple mode'
(is (not (re-find #"Switch to simple mode" html))
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))