Compare commits
49 Commits
df32100ca2
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 85aaf7b759 | |||
| 3641846f70 | |||
| d360316590 | |||
| 8215e6376d | |||
| 3759258ebe | |||
| 0e02c489e0 | |||
| 917b7f3857 | |||
| a8d8a8d111 | |||
| 360847fa58 | |||
| 55650c2dab | |||
| 19186097d5 | |||
| 1f6395382d | |||
| d52159637e | |||
| 3648597031 | |||
| 901d9eb508 | |||
| 569e52d1c1 | |||
| 9cc3418b1b | |||
| a1098b28f8 | |||
| b6649a3d1d | |||
| 38ae6f460f | |||
| e156d8bfd8 | |||
| 5c2cf8a631 | |||
| b8a0e9c3dc | |||
| 9659164fdc | |||
| 8f0a474fa8 | |||
| 6814cf1b15 | |||
| 3441ae63b4 | |||
| 79ddda624a | |||
| cbb9bc750d | |||
| a7ac7eae35 | |||
| c6699dd05a | |||
| 1be83a01f5 | |||
| ebd91f1911 | |||
| c9a587a8c5 | |||
| 9997d60de1 | |||
| 06fb0ea067 | |||
| 9a7d0b8b18 | |||
| 70a3db9a64 | |||
| 4e22fb1d82 | |||
| a88dcf4122 | |||
| 00b5303c28 | |||
| ab1a2c3368 | |||
| 724b6d82f5 | |||
| 6500c44909 | |||
| 2e4152e3fc | |||
| 6ce6a6e0c7 | |||
| 17eebe5628 | |||
| e5a2d0bbba | |||
| 7db1e07512 |
177
.agents/skills/frontend-design/LICENSE.txt
Normal file
177
.agents/skills/frontend-design/LICENSE.txt
Normal 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
|
||||
42
.agents/skills/frontend-design/SKILL.md
Normal file
42
.agents/skills/frontend-design/SKILL.md
Normal 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.
|
||||
1
.claude/skills/agent-browser
Symbolic link
1
.claude/skills/agent-browser
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/agent-browser
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,6 +8,8 @@ pom.xml.asc
|
||||
*.class
|
||||
/.lein-*
|
||||
/.nrepl-port
|
||||
nrepl-port
|
||||
.http-port
|
||||
resources/public/js/compiled
|
||||
*.log
|
||||
examples/
|
||||
@@ -49,3 +51,6 @@ sysco-poller/**/*.csv
|
||||
.tmp/**
|
||||
playwright-report/**
|
||||
test-results/**
|
||||
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
|
||||
/tmp/*
|
||||
!/tmp/.gitkeep
|
||||
|
||||
8
.opencode/opencode.json
Normal file
8
.opencode/opencode.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"edit": {
|
||||
"src/*": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
AGENTS.md
17
AGENTS.md
@@ -1,5 +1,9 @@
|
||||
# Integreat Development Guide
|
||||
|
||||
## Temporary Files
|
||||
|
||||
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
### Build
|
||||
@@ -20,6 +24,11 @@ clj-nrepl-eval -p PORT "(+ 1 2 3)" # evaluate clojure code
|
||||
|
||||
```
|
||||
|
||||
### Editing clojure
|
||||
|
||||
When editing clojure, use the clojure-mcp editing tools, or ask @clojure-author to make the change. It is critical that you
|
||||
do not use the file editing tools unless absolutely necessary.
|
||||
|
||||
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
|
||||
@@ -29,6 +38,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
125
CLAUDE.md
@@ -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
106
config/core.clj
Normal 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))))
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
date: 2026-06-01
|
||||
topic: manual-transaction-import-ssr
|
||||
focus: Port the master-branch "manual import transactions" feature to the SSR/alpine/htmx stack, modeled on the SSR ledger import, preserving all validations, starting from a failing e2e test, with minimal core-component change.
|
||||
mode: repo-grounded
|
||||
---
|
||||
|
||||
# Ideation: Porting Manual Bank-Transaction Import to SSR
|
||||
|
||||
## Grounding Context (Codebase)
|
||||
|
||||
Three reference points were read in full:
|
||||
|
||||
**1. The master feature (what we must reproduce).**
|
||||
- UI: `src/cljs/auto_ap/views/pages/transactions/manual.cljs` — a re-frame modal titled "Import Transactions" with a single `<textarea>` ("Yodlee manual import table"). User pastes tab-separated Yodlee data, clicks "Import". POSTs `(:data)` as EDN to `/api/transactions/batch-upload`.
|
||||
- Route/handler: `src/clj/auto_ap/routes/invoices.clj:241` `batch-upload-transactions` → `assert-admin`, then `manual/import-batch (manual/tabulate-data data)`.
|
||||
- Parsing: `src/clj/auto_ap/import/manual.clj` — `tabulate-data` reads CSV with `\tab` separator, drops the header row, and maps **fixed positional columns**: `[:status :raw-date :description-original :high-level-category nil nil :amount nil nil nil nil nil :bank-account-code :client-code]`.
|
||||
- Per-row mapping/validation: `manual->transaction` uses `import.manual.common/assoc-or-error` to accumulate errors for: **client lookup** (by bank-account-code → client), **bank-account lookup** (by code), **date parse** (`parse-date`, requires `MM/dd/yyyy`), **amount parse** (`parse-amount`).
|
||||
- Engine: `import.manual/import-batch` builds lookups, calls `t/start-import-batch :import-source/manual`, applies `t/apply-synthetic-ids` (dedupe key), imports only rows with no `:errors`, returns stats `{:import-batch/imported ... :failed-validation N :sample-error "..."}`.
|
||||
- Deeper validations live in `src/clj/auto_ap/import/transactions.clj` `categorize-transaction`: status must be `"POSTED"`, transaction date must be after `bank-account/start-date` and after `client/locked-until`, duplicate detection via `:transaction/id` (extant cache), missing client/bank-account/id → `:error`, plus suppression. Synthetic-id (`apply-synthetic-ids`/`synthetic-key`) gives idempotent re-import.
|
||||
|
||||
**2. The SSR ledger import (the pattern to emulate).**
|
||||
- `src/clj/auto_ap/ssr/ledger.clj` implements a **dedicated two-stage page**, not a modal:
|
||||
- `external-import-text-form*` (~L246): alpine `x-data {clipboard}`, a hidden `text-area` bound `x-model`, an `hx-post` to `::route/external-import-parse` triggered on a `"pasted"` event; a "Load from clipboard" button reads `navigator.clipboard`.
|
||||
- `external-import-parse` (~L373): re-renders `external-import-form*` with `:just-parsed? true`.
|
||||
- `external-import-table-form*` (~L117): renders parsed rows into an **editable** `com/data-grid-card` — each cell is a `fc/with-field` text/money input (form-cursor round-trips values + errors); per-row error/warning badge with tooltip; "Import" `hx-post`s to `::route/external-import-import`.
|
||||
- Validation: malli `parse-form-schema` (~L341) with `:decode/string tsv->import-data` + `:decode/arbitrary` (vector→map) enforces shape (min-length, `clj-date-schema`, `money`, location max 2). Business validation in `add-errors`/`table->entries` (~L380–504) accumulates `[message status]` pairs (`:error`/`:warn`) at entry and line-item level; `flatten-errors` maps them to form-cursor field paths `[[:table idx] message status]`.
|
||||
- `import-ledger` (~L554): splits good/ignored(warn-only)/bad entries; `throw+` `:field-validation` with `:form-errors` if any bad; upserts hidden vendors; retracts+re-inserts good entries (idempotent via `:journal-entry/external-id`); touches solr; returns `{:successful :ignored :form-errors}`. `external-import-import` wraps it in an `html-response` with an `hx-trigger` notification.
|
||||
|
||||
**3. THE KEY DISCOVERY — scaffolding already exists, handlers do not.**
|
||||
- `src/cljc/auto_ap/routes/transactions.cljc` **already declares** the route names, mirroring ledger exactly:
|
||||
```
|
||||
"/external-new" ::external-page
|
||||
"/external-import-new" {"" ::external-import-page
|
||||
"/parse" ::external-import-parse
|
||||
"/import" ::external-import-import}
|
||||
```
|
||||
- But `src/clj/auto_ap/ssr/transaction.clj` `key->handler` (L101) wires **only** `page/table/csv/bank-account-filter/bulk-delete` (+ `edit/` + `bulk-code/`). **No handler exists for any `external-import-*` route.** The aside nav (`ssr/components/aside.clj`) wires the *ledger* import nav (`::ledger-routes/external-import-page`) but there is no transaction-import nav entry. So the routes are declared dead ends; the gap is handlers + UI + validation + nav + tests.
|
||||
|
||||
**Test conventions.** `test/clj/auto_ap/ssr/ledger_test.clj` is the template: pure-fn tests (`tsv->import-data`, `trim-header`, `line->id`, `flatten-errors`), validation tests (`add-errors` per error type), tx-building tests (`entry->tx`), and end-to-end import tests (`import-ledger` against Datomic via `wrap-setup` + `setup-test-data` + `admin-token`). `test/clj/auto_ap/test-server.clj` is a Playwright/browser e2e harness with `wrap-test-auth` and seeded `setup-test-data`. Existing engine unit tests: `test/clj/auto_ap/import/transactions_test.clj` already covers `categorize-transaction`.
|
||||
|
||||
## Topic Axes
|
||||
|
||||
- **Input/paste fidelity** — keeping the exact Yodlee positional-column paste payload (req #1)
|
||||
- **UI flow & surface** — modal vs. dedicated two-stage paste→review→import page (req #2)
|
||||
- **Validation architecture** — where shape vs. business rules live, and how errors surface (req #3)
|
||||
- **Core/backend reuse** — how much of `import.transactions` / `import.manual` is reused vs. reimplemented (req #5)
|
||||
- **Test strategy** — failing-first e2e, then unit coverage (req #4)
|
||||
|
||||
## The Central Design Fork
|
||||
|
||||
Requirements #1 ("paste the exact same content") and #2 ("follow ledger patterns") pull in slightly different directions, but resolve cleanly:
|
||||
|
||||
- **Input format stays identical** — the user still copies the same Yodlee table and pastes the same tab-separated positional columns. We reuse `manual/columns` + `manual/tabulate-data` for the *paste shape*; we do **not** switch to ledger's named-header columns.
|
||||
- **Everything downstream follows ledger** — dedicated page, clipboard paste, editable review grid, per-row error/warning badges, re-validate loop, notification on import.
|
||||
|
||||
The one decision genuinely worth the user's input is **how much of the ledger UX to adopt**: the full editable review grid (idea #5, higher value, more work) vs. a lighter paste→validate→summary page closer to master (still SSR, less scope). Both are presented below as ranked survivors; this is the seed question for brainstorming.
|
||||
|
||||
## Ranked Ideas
|
||||
|
||||
### 1. Wire the pre-scaffolded routes into a dedicated two-stage SSR import page
|
||||
**Description:** Implement `external-import-page` / `external-import-parse` / `external-import-import` handlers in a new `src/clj/auto_ap/ssr/transaction/import.clj`, add them to `ssr/transaction.clj` `key->handler` (with the same middleware stack: `wrap-must {:activity :view :subject :transaction}`, `wrap-client-redirect-unauthenticated`, etc.), and add a transaction "Import" nav entry in `aside.clj` parallel to the ledger one. The page structure mirrors `ledger/external-import-page`: breadcrumb → clipboard script → paste form → review table.
|
||||
**Axis:** UI flow & surface
|
||||
**Basis:** `direct:` `routes/transactions.cljc` already declares `::external-import-page/parse/import` but `ssr/transaction.clj:101 key->handler` wires none of them; `ssr/ledger.clj:686 key->handler` shows the exact wiring shape to copy.
|
||||
**Rationale:** The routing contract already exists and is unused — the cheapest, lowest-risk foundation, and it's what makes req #2 ("like the ledger import") literally true at the routing/page level.
|
||||
**Downsides:** Adds a new namespace; needs a nav entry and middleware parity to avoid auth gaps.
|
||||
**Confidence:** 95%
|
||||
**Complexity:** Low
|
||||
**Status:** Unexplored
|
||||
|
||||
### 2. Preserve the exact Yodlee positional paste format (req #1)
|
||||
**Description:** Reuse `import.manual/columns` + `tabulate-data` (or a malli `:decode/string` wrapper around them) for parsing the pasted TSV, instead of ledger's named-header `tsv->import-data`. Keep the same column positions (`:status :raw-date :description-original ... :amount ... :bank-account-code :client-code`) so users paste identical content.
|
||||
**Axis:** Input/paste fidelity
|
||||
**Basis:** `direct:` Requirement #1 ("Paste the exact same type of content as was in the master branch version") + `import/manual.clj:10` fixed `columns` vector; the master textarea label is literally "Yodlee manual import table".
|
||||
**Rationale:** Users' upstream copy/paste habit (and the Yodlee export shape) is the contract that must not break; the ledger named-header approach would silently change what content is valid.
|
||||
**Downsides:** Positional columns are brittle if Yodlee changes column order — but that's already the status quo, not a regression.
|
||||
**Confidence:** 90%
|
||||
**Complexity:** Low
|
||||
**Status:** Unexplored
|
||||
|
||||
### 3. Reuse the `import.transactions` engine as the backend (req #5)
|
||||
**Description:** The import handler maps parsed rows → `:transaction/*` maps via the existing `manual->transaction` shape, then drives `import.transactions/start-import-batch :import-source/manual` + `import-transaction!` + `finish!` + `get-stats` — exactly as `import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only; the categorization, rule-matching, payment/deposit clearing, synthetic-id dedupe, and audit-transact paths are untouched.
|
||||
**Axis:** Core/backend reuse
|
||||
**Basis:** `direct:` Requirement #5 ("Minimally, if at all, change any core components") + `import/manual.clj:32 import-batch` already encapsulates the whole engine call; `import/transactions.clj` is covered by `transactions_test.clj`.
|
||||
**Rationale:** This is the battle-tested core. Re-implementing it ledger-style would risk dropping validations (`categorize-transaction`) and duplicate a large, audited code path. Wrapping it keeps core change near zero.
|
||||
**Downsides:** `import-batch` returns summary stats, not per-row form-cursor errors — so the pre-validation layer (idea #4) must surface row errors *before* the engine runs.
|
||||
**Confidence:** 90%
|
||||
**Complexity:** Medium
|
||||
**Status:** Unexplored
|
||||
|
||||
### 4. Two-tier validation: malli shape-parse + a transaction `add-errors` business layer
|
||||
**Description:** Mirror ledger's split. Tier 1: a malli `parse-form-schema` decodes the pasted TSV and coerces/validates *shape* (date parses as `MM/dd/yyyy`, amount parses, required fields present) — reusing `manual.common/parse-date`/`parse-amount` as `:decode`/predicate fns. Tier 2: a transaction-specific `add-errors`/`table->entries` adds *business* errors/warnings by reusing the predicates already encoded in `categorize-transaction`: client-not-found (`:error`), bank-account-not-found (`:error`), date before `bank-account/start-date` (`:warn`/`:not-ready`), client locked-until (`:warn`), status not `POSTED` (`:not-ready`), already-imported/extant (`:warn`). Errors are `[message status]` pairs surfaced via `flatten-errors` → form-cursor field paths.
|
||||
**Axis:** Validation architecture
|
||||
**Basis:** `direct:` Requirement #3 ("every validation maintained… but doesn't have to follow the same structure — make it like the ledger import"). Maps master validations (`manual.clj:23-30`, `transactions.clj:191-225 categorize-transaction`) onto ledger's `add-errors`/`flatten-errors` shape (`ledger.clj:380-519`).
|
||||
**Rationale:** Gives the ledger-style inline error UX while guaranteeing 1:1 validation parity — each master check becomes an explicit `add-errors` clause, which is also directly unit-testable (one test per error type, like `ledger_test/add-errors-test`).
|
||||
**Downsides:** Some checks (extant/duplicate, start-date, locked-until) need a DB read at validation time that master does lazily inside the engine — must decide whether to pre-check or let the engine's stats report them. Risk of double-validation drift if engine and pre-validator disagree.
|
||||
**Confidence:** 80%
|
||||
**Complexity:** High
|
||||
**Status:** Unexplored
|
||||
|
||||
### 5. Editable review grid with per-row error/warning badges and a re-validate loop
|
||||
**Description:** After paste+parse, render rows into a `com/data-grid-card` where each field (date, amount, description, bank-account-code, client-code) is an editable `fc/with-field` input, with `com/validated-field` error display and a per-row alert badge+tooltip — exactly like `ledger/external-import-table-form*`. The user can fix a wrong client/bank-account code or date inline and re-submit; only clean rows import, warn-only rows are skipped, error rows block. A "Show table" toggle keeps the default view compact.
|
||||
**Axis:** UI flow & surface / validation surfacing
|
||||
**Basis:** `direct:` Requirement #2 ("follow slightly better design patterns, like how the ledger import works"). `ledger.clj:117-244` is the editable-grid implementation to copy; master has no inline correction at all (fire-and-forget modal + summary stats).
|
||||
**Rationale:** This is the concrete UX upgrade over master and the main reason to model on ledger — turning "paste, pray, read a stats blob" into "paste, see exactly which rows are wrong and why, fix them, import."
|
||||
**Downsides:** Highest-effort idea; form-cursor round-tripping of an editable grid is the trickiest part of the ledger code. If scope must shrink, a read-only review table + summary (lighter survivor) is the fallback.
|
||||
**Confidence:** 75%
|
||||
**Complexity:** High
|
||||
**Status:** Unexplored
|
||||
|
||||
### 6. Preserve idempotent re-import via synthetic-id duplicate detection, surfaced as "already imported"
|
||||
**Description:** Keep `apply-synthetic-ids`/`synthetic-key` so re-pasting the same export is idempotent (the engine categorizes extant rows as `:extant` and skips them). Surface this in the review grid as a `:warn`-level "already imported" badge rather than silently dropping it, so the user understands why a row didn't import.
|
||||
**Axis:** Validation architecture
|
||||
**Basis:** `direct:` `import/transactions.clj:405-421 apply-synthetic-ids` + `categorize-transaction:192-225` extant handling. This is an existing master behavior that req #3 requires us to maintain.
|
||||
**Rationale:** Duplicate-safety is an easy validation to lose in a port; making it visible (vs. master's opaque stats) is a small, high-trust UX win that costs almost nothing on top of idea #5.
|
||||
**Downsides:** Requires a DB read of existing `:transaction/id`s at validation time (or reading it back from engine stats post-import).
|
||||
**Confidence:** 80%
|
||||
**Complexity:** Low
|
||||
**Status:** Unexplored
|
||||
|
||||
### 7. Start with a failing Playwright e2e, then backfill ledger_test-style unit coverage (req #4)
|
||||
**Description:** First commit: a failing e2e (against `test-server`) that dev-logs in, navigates dashboard → transactions → Import, pastes a known-good Yodlee TSV into the paste box, asserts parsed rows render, clicks Import, and asserts the transactions appear / a "N imported" notification fires. It fails initially (no handler/nav). Then make it pass incrementally: route+page (idea #1) → parse (#2/#4 tier 1) → review grid (#5) → import via engine (#3) → business validation (#4 tier 2). Backstop with unit tests mirroring `ledger_test.clj`: `tabulate-data`/parse, each `add-errors` validation clause, and an end-to-end `import` test against Datomic. Reuse the existing `transactions_test.clj` `categorize-transaction` coverage as the validation-parity oracle.
|
||||
**Axis:** Test strategy
|
||||
**Basis:** `direct:` Requirement #4 ("Write detailed acceptance criteria, and start with a failing e2e test, making it pass over time"). `test-server.clj` already provides the browser harness + test auth; `ledger_test.clj` provides the unit-test template.
|
||||
**Rationale:** A red e2e pins down the acceptance contract before any handler exists and gives an unambiguous "done" signal; the unit layer locks in validation parity clause-by-clause so req #3 can't silently regress.
|
||||
**Downsides:** e2e clipboard paste may need a direct `type`-into-textarea path (or a test seam) since `navigator.clipboard.read()` is awkward to drive headless — plan a paste fallback the test can use.
|
||||
**Confidence:** 85%
|
||||
**Complexity:** Medium
|
||||
**Status:** Unexplored
|
||||
|
||||
## Draft Acceptance Criteria (seed for brainstorm/plan, per req #4)
|
||||
|
||||
**Routing & access**
|
||||
- [ ] `GET /transactions/external-import-new` renders an import page (admin-gated, same middleware as other transaction routes); 401/redirect for unauthenticated.
|
||||
- [ ] A "Import" nav entry appears under the transactions section, active on the import route.
|
||||
|
||||
**Paste & parse (req #1)**
|
||||
- [ ] Pasting the exact master Yodlee TSV (same positional columns) parses into the same field set as `manual/tabulate-data`.
|
||||
- [ ] Header row is dropped; blank rows ignored.
|
||||
- [ ] `POST …/parse` re-renders the page with a "N rows found" banner and the review table.
|
||||
|
||||
**Validation parity (req #3)** — each must produce a visible, row-attributed message:
|
||||
- [ ] Client not found for bank-account-code → error.
|
||||
- [ ] Bank account not found by code → error.
|
||||
- [ ] Date not `MM/dd/yyyy` / unparseable → error.
|
||||
- [ ] Amount unparseable → error.
|
||||
- [ ] Status ≠ `POSTED` → not-imported (warn/not-ready).
|
||||
- [ ] Date before `bank-account/start-date` → not-imported (not-ready).
|
||||
- [ ] Date on/before `client/locked-until` → not-imported (not-ready).
|
||||
- [ ] Already-imported (synthetic-id extant) row → skipped, surfaced as warn.
|
||||
- [ ] Missing client / bank-account / id → error.
|
||||
|
||||
**Import (req #5, minimal core change)**
|
||||
- [ ] `POST …/import` runs the existing `import.transactions` engine via the `:import-source/manual` batch path; no change to `categorize-transaction`/`import-transaction!`/`apply-synthetic-ids`.
|
||||
- [ ] Only clean rows import; warn-only rows skipped; any error blocks (or imports clean rows + reports errors — match master's "import valid, report failed-validation").
|
||||
- [ ] Success notification reports counts (imported / skipped / errors), mirroring master's stats.
|
||||
- [ ] Re-importing the same paste is idempotent (no duplicates).
|
||||
|
||||
**Tests (req #4)**
|
||||
- [ ] A Playwright e2e covering paste → review → import → assert, committed red first, green at the end.
|
||||
- [ ] Unit tests per validation clause + a Datomic-backed end-to-end import test, modeled on `ledger_test.clj`.
|
||||
|
||||
## Failing-First e2e: concrete starting point
|
||||
|
||||
Add `test/clj/auto_ap/ssr/transaction/import_test.clj` (unit) and a Playwright spec driven through `test-server`. The e2e is the first artifact and is expected to fail because no `external-import` handler is wired in `ssr/transaction.clj`. Make it green by walking ideas #1 → #2 → #5 → #3 → #4 in that order; the unit suite grows alongside #4.
|
||||
|
||||
## Rejection Summary
|
||||
|
||||
| # | Idea | Reason Rejected |
|
||||
|---|------|-----------------|
|
||||
| 1 | Switch paste format to ledger's named-header columns | Violates req #1 — users paste the exact Yodlee positional export; changing valid input is a silent regression |
|
||||
| 2 | Keep it a re-frame/CLJS modal | Branch eliminated the CLJS app; contradicts the whole port. (An SSR htmx *modal* was considered but rejected vs. the dedicated page — ledger uses a page and the editable review grid needs the room) |
|
||||
| 3 | Reimplement validation entirely ledger-style, ignoring `import.transactions` | Duplicates audited `categorize-transaction` logic, risks dropping validations (req #3), and churns core (violates req #5) |
|
||||
| 4 | Async/streaming import for large pastes | Scope overrun — master is synchronous; YAGNI for the manual paste workflow |
|
||||
| 5 | Add CSV file-upload alongside paste | Scope overrun — not part of the master manual-import feature |
|
||||
| 6 | Replace `import-batch` stats with a bespoke result type | Unnecessary core change; the existing stats map already carries imported/failed/sample-error |
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
date: 2026-06-01
|
||||
type: feat
|
||||
status: active
|
||||
plan_id: 2026-06-01-001
|
||||
title: "feat: Port manual bank-transaction import to SSR (alpine/htmx)"
|
||||
depth: standard
|
||||
origin: docs/ideation/2026-06-01-manual-transaction-import-ssr-ideation.md
|
||||
---
|
||||
|
||||
# feat: Port Manual Bank-Transaction Import to SSR
|
||||
|
||||
## Summary
|
||||
|
||||
Port the master-branch "manual import transactions" feature into the SSR/alpinejs/htmx stack by implementing the `external-import` handlers that `src/cljc/auto_ap/routes/transactions.cljc` already declares but that no handler currently serves. The feature is a dedicated two-stage page — paste the same Yodlee positional-column TSV → an editable review grid with per-row error/warning badges → import — modeled directly on the SSR ledger import (`src/clj/auto_ap/ssr/ledger.clj`). Validation follows the ledger's `add-errors` shape but preserves every master validation, and the actual write reuses the existing `auto-ap.import.transactions` engine unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
On `master`, admins import bank transactions by pasting a tab-separated Yodlee export into a re-frame modal (`src/cljs/auto_ap/views/pages/transactions/manual.cljs`) that POSTs EDN to `/api/transactions/batch-upload`. This branch removed the ClojureScript React app and re-implemented the transactions surface server-side, but the manual-import feature was never ported — so admins on this branch cannot manually import transactions at all.
|
||||
|
||||
The route names are already scaffolded (`::external-page`, `::external-import-page`, `::external-import-parse`, `::external-import-import` in `routes/transactions.cljc`) but `src/clj/auto_ap/ssr/transaction.clj` wires no handlers for them — they are declared dead ends. The work is to fill that gap with handlers + UI + validation + nav + tests, mirroring the already-shipped ledger import.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope**
|
||||
- A dedicated SSR import page at `/transaction2/external-import-new` (+ `/parse`, `/import` sub-routes), admin-gated with the same middleware posture as other transaction routes and the ledger import.
|
||||
- Reuse of the exact Yodlee positional-column paste format (no named-header columns).
|
||||
- An editable review grid with inline per-field editing and per-row error/warning badges (ledger-style, form-cursor driven).
|
||||
- Two-tier validation preserving every master validation, with the agreed severity split.
|
||||
- Import via the existing `auto-ap.import.transactions` engine, block-whole-batch on hard errors.
|
||||
- A transactions-section "Import" nav entry and an import-result notification.
|
||||
- A Playwright e2e (committed failing first) plus unit/integration tests modeled on `test/clj/auto_ap/ssr/ledger_test.clj`.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- CSV file upload as an alternative to paste.
|
||||
- Asynchronous/streaming import for very large pastes.
|
||||
- Any change to `categorize-transaction` or engine internals.
|
||||
|
||||
**Outside this change**
|
||||
- Named-header column format (rejected — would silently change valid input).
|
||||
- A bespoke import-result type replacing the engine's stats map.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
1. **Full editable review grid, block-whole-batch on hard error** (from brainstorm). Any remaining hard error blocks the entire import (ledger behavior: `throw+ {:type :field-validation ...}`, re-render the grid with errors highlighted); warn-level rows skip just that row and the rest import. Rationale: with an editable grid, the user can fix fixable problems inline, so "nothing imports until clean-or-skippable" is the coherent contract.
|
||||
|
||||
2. **Severity split between fixable errors and inherent warnings.**
|
||||
- **Hard errors (block, must fix inline):** unparseable/invalid date (must match `MM/dd/yyyy`), unparseable amount, unknown client code (no client for the bank-account-code), unknown bank-account code, missing required fields.
|
||||
- **Warnings (skip that row, import the rest):** status ≠ `"POSTED"`, transaction date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported (synthetic-id `extant`).
|
||||
Rationale: fixable problems are correctable by editing a cell; inherent skip-conditions are facts about the data/account that editing cannot change, so they should not block the batch — this also reproduces master's "import valid, report the rest" outcome for those rows.
|
||||
|
||||
3. **Reuse the exact Yodlee positional paste format** (req #1). Parse with the master positional `columns` mapping (`auto-ap.import.manual/columns` + `tabulate-data` shape), not ledger's named-header `tsv->import-data`. Rationale: admins paste an unchanged Yodlee export; changing valid input is a silent regression.
|
||||
|
||||
4. **Reuse the `import.transactions` engine unchanged** (req #5). The import handler maps reviewed rows → `:transaction/*` maps (the `auto-ap.import.manual/manual->transaction` shape) and drives `start-import-batch :import-source/manual` → `import-transaction!` → `finish!` → `get-stats`, with `apply-synthetic-ids` for dedupe — exactly as `auto-ap.import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only.
|
||||
|
||||
5. **Preview/engine parity via shared predicates** (the key design tension). The warn-level conditions shown in the grid before import (`not-ready` from start-date/locked-until, `extant`/already-imported, non-`POSTED`) and the engine's write-time `categorize-transaction` decisions must not drift. Decision: the pre-validation layer computes warn conditions by calling the **same** predicate functions the engine uses (`auto-ap.import.transactions/categorize-transaction` and its inputs — `get-existing` for extant, the bank-account `start-date`/`locked-until` checks), rather than re-deriving parallel logic. The grid is advisory display; the engine remains authoritative at write time, and because both read the same functions they agree. Hard-error (fixable) validations have no engine equivalent and live only in the pre-validation layer / malli schema.
|
||||
|
||||
6. **Testable paste path.** The ledger import populates a hidden textarea from `navigator.clipboard` via an alpine `@click`/`paste` handler, which is awkward to drive in headless Playwright. Decision: keep the "Load from clipboard" affordance, but ensure the paste textarea is fillable and that a `pasted`/`change` trigger fires the parse `hx-post`, so the e2e can set the value and dispatch the event without the clipboard API. (Implementation detail of how the trigger is wired is deferred to execution.)
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
Two-stage flow mirroring `ssr/ledger.clj`, on the transactions surface:
|
||||
|
||||
```
|
||||
GET /transaction2/external-import-new -> external-import-page (paste form + empty review area)
|
||||
POST /transaction2/external-import-new/parse -> external-import-parse (decode TSV -> validate -> render editable grid)
|
||||
POST /transaction2/external-import-new/import -> external-import-import (re-validate -> if any hard error: re-render grid (blocked);
|
||||
else run import.transactions engine on clean rows,
|
||||
skip warn rows, return notification with stats)
|
||||
```
|
||||
|
||||
*This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
|
||||
|
||||
Validation is two-tier:
|
||||
- **Tier 1 (shape, hard errors):** a malli parse-form-schema decodes the pasted TSV positionally (reusing the master column order) and coerces/flags shape problems — date parses as `MM/dd/yyyy`, amount parses, required fields present.
|
||||
- **Tier 2 (business):** a transaction `add-errors`/`table->entries` pass attaches `[message status]` pairs (`:error` / `:warn`) per row, with the hard/warn split from Decision 2, computing warn conditions from the shared engine predicates (Decision 5). `flatten-errors` maps them onto form-cursor field paths for the editable grid.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
Build order is **failing-e2e-first** (req #4): U1 lands the red acceptance test, then U2–U7 turn it green incrementally. Each feature-bearing unit also grows the unit-test suite in `test/clj/auto_ap/ssr/transaction/import_test.clj`.
|
||||
|
||||
### U1. Failing e2e acceptance test + deterministic import seed
|
||||
|
||||
**Goal:** Commit the end-to-end acceptance test (expected to fail) that defines "done", plus the deterministic test fixture it needs.
|
||||
**Requirements:** req #4; advances acceptance criteria AC-1, AC-2, AC-9, AC-10.
|
||||
**Dependencies:** none.
|
||||
**Files:**
|
||||
- `e2e/transaction-import.spec.ts` (new)
|
||||
- `test/clj/auto_ap/test_server.clj` (modify `seed-test-data` to give the seeded bank account a **fixed** `:bank-account/code`, e.g. `"TEST-CHK"`, since `test-bank-account` otherwise assigns a random code)
|
||||
**Approach:** Mirror `e2e/transaction-navigation.spec.ts` conventions (`x-clients: "mine"` header, `page.goto`, locators). The spec navigates to `/transaction2/external-import-new`, fills the paste box with a known-good Yodlee TSV whose bank-account-code/client-code match the seed (`TEST` client, `TEST-CHK` bank account), triggers parse, asserts parsed rows render in the review grid, clicks Import, and asserts a success notification with an imported count and that the imported transaction is visible on `/transaction2`. Include a second scenario pasting a row with an unknown client code and asserting a blocking error badge + that nothing imports. Drive paste by filling the textarea and dispatching the parse trigger (Decision 6), not the clipboard API.
|
||||
**Patterns to follow:** `e2e/transaction-navigation.spec.ts`, `e2e/bulk-code-transactions.spec.ts`; seed shape in `test/clj/auto_ap/test_server.clj` `seed-test-data`.
|
||||
**Test scenarios:**
|
||||
- Covers AE/AC-1, AC-2: paste valid TSV → rows render → import → "N imported" notification → transaction appears on the list page.
|
||||
- Covers AC-9: paste TSV with an unknown client code → row shows a blocking error badge, Import is blocked, no transaction created.
|
||||
- Edge: empty paste → no rows / friendly empty state (assert no crash).
|
||||
**Verification:** `npx playwright test e2e/transaction-import.spec.ts` runs and **fails** at this unit (no handler yet); the seed change does not break existing e2e specs (`npx playwright test` green except the new file).
|
||||
**Execution note:** Start red. This is the acceptance contract; do not weaken it to pass — make U2–U7 satisfy it.
|
||||
|
||||
### U2. Wire routes and render the import page shell
|
||||
|
||||
**Goal:** Make `/transaction2/external-import-new` serve a real page with the correct admin middleware; wire `parse`/`import` routes to placeholder handlers.
|
||||
**Requirements:** AC-1, AC-12 (auth); req #2.
|
||||
**Dependencies:** U1.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (new — namespace for the import handlers)
|
||||
- `src/clj/auto_ap/ssr/transaction.clj` (merge the new `key->handler` entries into the existing map at the `key->handler` def)
|
||||
**Approach:** Create `external-import-page` returning a `base-page` + `com/page` with breadcrumb ("Transactions" → "Import"), the clipboard helper script, and a forms container (initially just the paste form placeholder). Wire `::route/external-import-page`, `::route/external-import-parse`, `::route/external-import-import` into the transaction `key->handler` with the same middleware chain ledger uses for its import routes (`wrap-schema-enforce`/`wrap-form-4xx-2`/`wrap-schema-decode`/`wrap-nested-form-params` on parse/import) under the transaction page middleware (`wrap-must {:activity :import :subject :transaction}` analogous to ledger's `:subject :ledger`, `wrap-client-redirect-unauthenticated`). Confirm the correct `:activity`/`:subject` against the permissions model.
|
||||
**Patterns to follow:** `src/clj/auto_ap/ssr/ledger.clj` `external-import-page` and `key->handler` (~lines 276–318, 686–718); `src/clj/auto_ap/ssr/transaction.clj` existing `key->handler` (~line 101).
|
||||
**Test scenarios:**
|
||||
- Happy path: `GET` the page as admin → 200, renders the paste form container.
|
||||
- Error/auth: unauthenticated request → redirect/401 per `wrap-client-redirect-unauthenticated`.
|
||||
**Verification:** Page loads at the route in the running app and in `test_server`; the e2e gets past navigation (still fails later in the flow).
|
||||
|
||||
### U3. Paste + parse using the master positional column format
|
||||
|
||||
**Goal:** Parse the pasted Yodlee TSV (exact master columns) into rows and render them; wire the paste form's `pasted`-triggered `hx-post` to the parse handler.
|
||||
**Requirements:** req #1, req #2; AC-1, AC-3, AC-4.
|
||||
**Dependencies:** U2.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-text-form*`, `external-import-parse`, the parse malli schema, and a positional `tsv->rows` decode)
|
||||
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (new)
|
||||
**Approach:** Reuse the master column order from `auto-ap.import.manual/columns` and `tabulate-data` (CSV read with `\tab`, drop header) to map positional columns. Define a malli `parse-form-schema` (ledger-style) whose `:table` field uses a `:decode/string` that runs the positional parse and a per-row `:decode/arbitrary` to build row maps; encode Tier-1 shape constraints (date `MM/dd/yyyy`, amount parses, required fields) reusing `auto-ap.import.manual.common/parse-date`/`parse-amount` semantics. `external-import-parse` re-renders the forms fragment with `:just-parsed? true`. Keep the paste textarea fillable and fire the parse trigger on a `pasted`/`change` event (Decision 6).
|
||||
**Patterns to follow:** `ssr/ledger.clj` `external-import-text-form*`, `external-import-parse`, `tsv->import-data`, `parse-form-schema` (~lines 246–375); `import/manual.clj` `columns`/`tabulate-data`; `import/manual/common.clj` `parse-date`/`parse-amount`.
|
||||
**Test scenarios:**
|
||||
- Happy path: a known Yodlee TSV string decodes to the expected row count with the expected field keys/values (positional mapping correct).
|
||||
- Header handling: first row dropped; blank rows ignored.
|
||||
- Edge: amount with currency formatting parses; amount unparseable flagged at Tier 1.
|
||||
- Edge: date not `MM/dd/yyyy` flagged at Tier 1; valid date parses.
|
||||
- Covers AC-3: pasting the exact master column layout yields the same field set master's `tabulate-data` produced.
|
||||
**Verification:** After paste, the parsed rows render (read-only at this unit is acceptable); parse unit tests green.
|
||||
|
||||
### U4. Editable review grid with per-row error/warning badges
|
||||
|
||||
**Goal:** Render parsed rows into an editable `data-grid` where each field is editable and per-row error/warning badges show, with a "Show table" toggle and an Import button.
|
||||
**Requirements:** req #2; AC-5, AC-6.
|
||||
**Dependencies:** U3.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-table-form*`, `external-import-form*`)
|
||||
**Approach:** Mirror `ledger/external-import-table-form*` using form-cursor (`fc/start-form`, `fc/with-field`, `fc/cursor-map`, `fc/field-value`/`field-name`/`field-errors`) and `com/data-grid-card` / `com/validated-field` / `com/text-input` / `com/money-input`. Columns reflect the transaction row shape (date, description, amount, bank-account-code, client-code, status). Per-row badge summarizes that row's error/warn state with a tooltip listing messages (red for `:error`, yellow for `:warn`). A parsed-summary banner shows row count + error/warning pill counts. Values round-trip on re-submit so inline edits persist.
|
||||
**Patterns to follow:** `ssr/ledger.clj` `external-import-table-form*` and `external-import-form*` (~lines 117–274).
|
||||
**Test scenarios:**
|
||||
- Test expectation: none for pure rendering structure beyond what U5 exercises — but include: rows with no errors render without a badge; rows with errors render a red badge; rows with only warnings render a yellow badge (assert via the rendered hiccup/markup in a handler-level test once U5 attaches errors).
|
||||
**Verification:** Parsed grid is visibly editable; badges appear once U5 attaches errors; e2e can see rows.
|
||||
|
||||
### U5. Two-tier validation preserving every master validation
|
||||
|
||||
**Goal:** Attach hard-error and warning statuses to rows per the severity split, reusing the engine's predicates for the warn conditions so the preview matches the engine.
|
||||
**Requirements:** req #3, req #5 (Decision 5); AC-7, AC-8, AC-9.
|
||||
**Dependencies:** U3 (Tier 1 shape errors), U4 (badges to display them).
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `add-errors`, `table->entries`, `flatten-errors`, `entry-error-types` analogues)
|
||||
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (extend)
|
||||
**Approach:** Build a transaction `add-errors` that, given lookups (client-by-bank-account-code, bank-account-by-code, bank-account `start-date`/`locked-until`, existing transaction ids), assigns:
|
||||
- **Hard errors:** unknown client code, unknown bank-account code, missing required fields. (Tier-1 date/amount errors already present from U3.)
|
||||
- **Warnings:** status ≠ `POSTED`; date before `bank-account/start-date`; date on/before `client/locked-until`; already-imported (synthetic-id present in existing ids).
|
||||
Compute the warn conditions by calling the same functions the engine uses — `auto-ap.import.transactions/categorize-transaction` (and its inputs `get-existing`, the `apply-synthetic-ids` key) — rather than parallel logic (Decision 5). `flatten-errors` maps `[message status]` onto form-cursor field paths so badges render against the right rows. Map every master validation explicitly (see `import/manual.clj manual->transaction` and `import/transactions.clj categorize-transaction`).
|
||||
**Patterns to follow:** `ssr/ledger.clj` `add-errors`/`table->entries`/`flatten-errors`/`entry-error-types` (~lines 380–523); `import/transactions.clj` `categorize-transaction`/`get-existing`/`apply-synthetic-ids`.
|
||||
**Test scenarios (one per validation, modeled on `ledger_test/add-errors-test`):**
|
||||
- Hard error: unknown client code → `:error` with a clear message.
|
||||
- Hard error: unknown bank-account code → `:error`.
|
||||
- Hard error: missing required field → `:error`.
|
||||
- (Tier 1) invalid date / unparseable amount → `:error`.
|
||||
- Warning: status ≠ `POSTED` → `:warn`, row skipped.
|
||||
- Warning: date before `bank-account/start-date` → `:warn`.
|
||||
- Warning: date on/before `client/locked-until` → `:warn`.
|
||||
- Warning: already-imported (extant synthetic id) → `:warn`.
|
||||
- Parity: a row the grid marks clean is categorized `:import` by `categorize-transaction`; a row marked warn-skip is categorized to the matching non-`:import` action (assert grid preview agrees with engine).
|
||||
- Pass-through: a fully valid row has no errors/warnings.
|
||||
**Verification:** Validation unit tests green; badges reflect the correct severities in the grid.
|
||||
|
||||
### U6. Import via the existing engine, block-on-error, with notification
|
||||
|
||||
**Goal:** Implement `external-import-import`: block the whole batch if any hard error remains; otherwise run the `import.transactions` engine on clean rows (skipping warn rows) and return a result notification.
|
||||
**Requirements:** req #5, Decisions 1 & 4; AC-2, AC-9, AC-10, AC-11.
|
||||
**Dependencies:** U5.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `rows->transactions`, `import-transactions`, `external-import-import`)
|
||||
**Approach:** Re-validate submitted (possibly edited) rows via U5. If any `:error` rows remain, `throw+ {:type :field-validation :form-errors ... :form-params ...}` and re-render the grid with errors (the `wrap-form-4xx-2` middleware handles re-render) — nothing imports. Otherwise map clean rows → `:transaction/*` maps using the `auto-ap.import.manual/manual->transaction` shape, apply `apply-synthetic-ids`, then drive `start-import-batch :import-source/manual` → `import-transaction!` (per row) → `finish!` → `get-stats`. Warn-only rows are excluded from the engine input (skipped). Return `html-response` re-rendering the form with an `hx-trigger` notification reporting counts from the engine stats (imported / skipped / not-ready / extant), mirroring master's stats surface.
|
||||
**Patterns to follow:** `ssr/ledger.clj` `import-ledger` + `external-import-import` (~lines 554–684); `import/manual.clj` `import-batch` (engine driving); `import/manual.clj` `manual->transaction`.
|
||||
**Test scenarios (modeled on `ledger_test` `import-ledger-*` tests, against Datomic via `wrap-setup`/`setup-test-data`/`admin-token`):**
|
||||
- Happy path: all-clean batch → engine imports all rows; stats report the imported count; transactions exist in the DB afterward.
|
||||
- Block-on-error: a batch with one hard-error row → throws `:field-validation`; **no** transactions are created (assert DB unchanged).
|
||||
- Warning skip: a batch with one warn-only row (e.g., non-`POSTED`) and clean rows → clean rows import, warn row skipped, stats reflect the skip.
|
||||
- Idempotency: importing the same paste twice → second run imports 0 new (extant/synthetic-id dedupe); no duplicates.
|
||||
- Integration: imported transaction carries `:import-source/manual` and is categorized/coded by the engine as it would be for any import (engine unchanged).
|
||||
**Verification:** Import unit/integration tests green; the e2e's import step succeeds and the transaction appears on the list page.
|
||||
|
||||
### U7. Transactions "Import" nav entry + final polish
|
||||
|
||||
**Goal:** Add an "Import" entry to the transactions section nav (parallel to the ledger import nav) and finish the parsed-summary banner / notification copy.
|
||||
**Requirements:** req #2; AC-1, AC-11.
|
||||
**Dependencies:** U2 (route exists), U6 (notification exists).
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/components/aside.clj` (add a transactions "Import" nav button + mark active on `::transaction-routes/external-import-page`)
|
||||
**Approach:** Mirror the ledger import nav entry in `aside.clj` — add a sub-menu button under the transactions section linking to `::transaction-routes/external-import-page`, active-highlighted on that matched route. Confirm the banner shows row counts + error/warning pills (from U4) and the success notification copy matches the engine stats.
|
||||
**Patterns to follow:** `ssr/components/aside.clj` ledger import nav (~lines 360–366) and the transactions sub-menu (~lines 285–298).
|
||||
**Test scenarios:**
|
||||
- Test expectation: none (navigation markup) — covered indirectly by the e2e navigating via the nav link; optionally assert the nav button renders with the correct href on the import route.
|
||||
**Verification:** Full `e2e/transaction-import.spec.ts` passes; nav link is present and active on the import page.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**Routing & access**
|
||||
- **AC-1.** `GET /transaction2/external-import-new` renders the import page for an admin; an "Import" nav entry under the transactions section links to it and is active there.
|
||||
- **AC-12.** Unauthenticated access redirects/401s per the standard transaction-route middleware.
|
||||
|
||||
**Paste & parse (req #1)**
|
||||
- **AC-3.** Pasting the exact master Yodlee positional TSV parses into the same field set as `auto-ap.import.manual/tabulate-data`; the header row is dropped and blank rows ignored.
|
||||
- **AC-4.** `POST .../parse` re-renders the page with a "N rows found" banner and the review grid.
|
||||
|
||||
**Review grid (req #2)**
|
||||
- **AC-5.** Parsed rows render in an editable grid; each field is editable and inline edits persist across re-submit.
|
||||
- **AC-6.** Each row shows an error badge (red) when it has a hard error, a warning badge (yellow) when it has only warnings, and no badge when clean; badges list messages on hover.
|
||||
|
||||
**Validation parity (req #3)** — each produces a visible, row-attributed message:
|
||||
- **AC-7.** Hard errors block: client not found, bank account not found, date not `MM/dd/yyyy`, amount unparseable, missing required field.
|
||||
- **AC-8.** Warnings skip just that row: status ≠ `POSTED`, date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported.
|
||||
- **AC-9.** With any remaining hard error, clicking Import blocks the whole batch (nothing imports) and re-renders the grid with errors highlighted.
|
||||
|
||||
**Import (req #5)**
|
||||
- **AC-2.** `POST .../import` imports the clean rows via the existing `import.transactions` engine on the `:import-source/manual` batch path; the success notification reports counts (imported / skipped / not-ready / extant).
|
||||
- **AC-10.** Re-importing the same paste is idempotent — no duplicate transactions (synthetic-id dedupe preserved).
|
||||
- **AC-11.** `categorize-transaction` and the engine internals are unchanged by this work.
|
||||
|
||||
**Tests (req #4)**
|
||||
- The Playwright e2e `e2e/transaction-import.spec.ts` exists, was committed failing first, and passes at the end.
|
||||
- Unit/integration tests in `test/clj/auto_ap/ssr/transaction/import_test.clj` cover each validation clause and the end-to-end import flow against Datomic.
|
||||
|
||||
---
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- **e2e (Playwright):** `e2e/transaction-import.spec.ts`, driven through `test/clj/auto_ap/test_server.clj` (real routes, injected test auth). Committed red in U1, green by U7.
|
||||
- **Unit/integration (clojure.test):** `test/clj/auto_ap/ssr/transaction/import_test.clj`, modeled on `test/clj/auto_ap/ssr/ledger_test.clj` — pure parse/format tests, one validation test per clause, and Datomic-backed import tests via `wrap-setup` / `setup-test-data` / `admin-token`. Run with `clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.transaction.import-test)"` per AGENTS.md (preferred over `lein test`).
|
||||
- **Validation-parity oracle:** existing `test/clj/auto_ap/import/transactions_test.clj` (`categorize-transaction`) backs Decision 5 — the warn-condition predicates the grid reuses are already under test.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Editing discipline (AGENTS.md):** all Clojure edits go through the clojure-mcp editing tools (or `@clojure-author`), not raw file edits; use `clojure-eval`/`clj-nrepl-eval` to compile-check and run tests. Run `lein cljfmt fix` before committing; `clj-paren-repair` on any file that won't compile.
|
||||
- **Shared component reuse:** the feature composes existing `ssr/components` (`data-grid-card`, `validated-field`, `text-input`, `money-input`, `button`, `checkbox`, `errors`, `pill`, `form-errors`) and `ssr/form-cursor` — no core-component changes expected (req #5). If a component genuinely needs a new option, prefer an additive, backward-compatible change and flag it.
|
||||
- **Test fixture change:** giving the seeded bank account a deterministic `:bank-account/code` in `test_server.clj` could affect other e2e specs that assume the random code; U1 verifies the existing suite stays green.
|
||||
- **Permissions:** confirm the `wrap-must` `:activity`/`:subject` for the import routes matches the permission model (ledger uses `{:activity :import :subject :ledger}`); use the transaction equivalent.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Preview/engine drift (highest risk).** Mitigated by Decision 5 — share the engine's predicates for warn conditions; the parity test in U5 asserts the grid and `categorize-transaction` agree.
|
||||
- **Headless clipboard paste.** Mitigated by Decision 6 — fillable textarea + explicit parse trigger so the e2e never needs `navigator.clipboard`.
|
||||
- **form-cursor round-tripping of an editable grid** is the trickiest ledger mechanic to copy; mitigate by mirroring `external-import-table-form*` closely and testing edit-persist-on-resubmit early (U4).
|
||||
- **Positional column brittleness** is inherited from master (Yodlee column order); not a regression, and out of scope to fix here.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Implementation Notes (execution-time unknowns)
|
||||
|
||||
- Exact helper/function names in the new `import.clj` namespace.
|
||||
- The precise malli `:decode` wiring for positional parsing (reuse vs. thin wrapper around `tabulate-data`).
|
||||
- The exact `:activity`/`:subject` keyword for the import-route `wrap-must` (verify against the permissions model).
|
||||
- The seeded bank-account code value and whether any existing e2e needs adjustment after making it deterministic.
|
||||
- Final notification/banner copy.
|
||||
@@ -0,0 +1,777 @@
|
||||
# SSR Form & Wizard Simplification — Migration Plan
|
||||
|
||||
> **Status:** Planning / for execution by an agent or engineer.
|
||||
> **Owner:** Bryce
|
||||
> **Type:** Refactor (no user-facing behavior change; parity required).
|
||||
|
||||
This plan describes a series of low-risk migrations that make the server-side
|
||||
rendered (SSR) forms and wizards substantially simpler. It is self-contained:
|
||||
every concept needed to execute is stated here, illustrated with code snippets.
|
||||
The work is sequenced so each migration is small, reversible, and *teaches a
|
||||
skill* that makes the next migration cheaper.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||
the DOM in place. This removes the class of bugs around stale state, lost
|
||||
focus/caret, and out-of-band patching.
|
||||
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||
fakery forces. The target: a cursor always begins at the top level of what the
|
||||
form consumes and walks down naturally from there. (Because the whole form is
|
||||
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||
position.)
|
||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||
small data-driven engine instead of protocols + middleware stacking, and
|
||||
**store each step's data in the session** (combined only at the end) instead
|
||||
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
|
||||
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
||||
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
||||
first-class HTML rather than a mix of Clojure keywords and strings.
|
||||
5. **Capture the migration method in a skill** that is created after the first
|
||||
successful migration and extended by every migration thereafter.
|
||||
|
||||
Net effect target: large reduction in lines of code, route count, and branching
|
||||
complexity, with measurably more reuse across similar forms.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why — the current pain (rationale)
|
||||
|
||||
### 2.1 In-place DOM mutation is fragile
|
||||
Re-rendering only fragments and patching the rest (via morph or out-of-band
|
||||
swaps) means the server and the DOM can disagree. Keeping a focused input alive
|
||||
through a patch requires keying tricks and guards. Re-rendering the **whole
|
||||
form** and letting the typed value ride along in the form is simpler and
|
||||
correct, *provided the input the user is typing in is never inside the region
|
||||
being swapped*.
|
||||
|
||||
### 2.2 Faking cursor positions forces duplicate functions
|
||||
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||
nested render function can run against a fragment. That fakery is fragile and
|
||||
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||
faked cursor and one that takes plain params for the cases where the fake can't
|
||||
be set up.
|
||||
|
||||
```clojure
|
||||
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||
(defn account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||
...))
|
||||
|
||||
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||
...)
|
||||
```
|
||||
|
||||
**Target:** the cursor starts at the top of the form's data and walks down
|
||||
naturally; a row render either takes explicit row data or receives a cursor the
|
||||
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||
|
||||
### 2.3 Single-step forms wear wizard costumes
|
||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
||||
overhead.
|
||||
|
||||
### 2.4 Multi-step wizards round-trip and merge a snapshot
|
||||
The genuine multi-step wizards carry the whole accumulating form state as an EDN
|
||||
snapshot in hidden fields, then rebuild it each request by merging the posted
|
||||
pieces back into the snapshot. The serialization needs custom readers, the merge
|
||||
logic is error-prone, and the page payload grows with every step. The fix is to
|
||||
**store each step's data in the session under its own key and combine only at the
|
||||
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
|
||||
|
||||
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
|
||||
The same attribute is sometimes a keyword and sometimes a string in the same
|
||||
file, and event handlers must be strings while structural Alpine attrs are
|
||||
keywords. There is no rule a reader (or an LLM) can rely on:
|
||||
|
||||
```clojure
|
||||
;; Both of these appear in one component file today:
|
||||
:x-ref "input" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
:x-model "value.value"
|
||||
"x-model" "search"
|
||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers must be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
|
||||
```html
|
||||
<input x-ref="input" x-model="value.value"
|
||||
@keydown.down.prevent.stop="tippy?.show()" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Target state (the patterns, with snippets)
|
||||
|
||||
These four patterns are what every migration moves code *toward*. The skill
|
||||
(§5) holds the canonical, growing version of each.
|
||||
|
||||
### 3.1 Whole-form HTMX swap doctrine
|
||||
|
||||
Decide per interactive control, in this priority order:
|
||||
|
||||
1. **No request** when the field affects nothing else. Its value rides along in
|
||||
the form and is read on submit.
|
||||
```html
|
||||
<!-- a memo / free-text field that influences nothing -->
|
||||
<input name="memo" /> <!-- no hx-* at all -->
|
||||
```
|
||||
2. **Targeted swap of a single isolated cell** when a field's effect is purely
|
||||
local. Give the cell a stable id and keep it out of the typed input's subtree.
|
||||
```html
|
||||
<!-- selecting an account only changes the valid Location options -->
|
||||
<select name="accounts[0][account]"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#account-location-0"
|
||||
hx-select="#account-location-0"
|
||||
hx-swap="outerHTML" hx-trigger="changed">
|
||||
</select>
|
||||
<div id="account-location-0"> ...location options... </div>
|
||||
```
|
||||
3. **Whole-form swap** when the change touches interdependent state (vendor,
|
||||
add/remove row, mode toggle, $/% radio). The form's hidden state rides along,
|
||||
so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||
```html
|
||||
<form id="wizard-form"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
|
||||
...
|
||||
</form>
|
||||
```
|
||||
4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global
|
||||
flash/toast, a nav badge, a modal mounted at the document root. If you are
|
||||
tempted to OOB something *inside the same feature*, that is a signal to
|
||||
**restructure the DOM so the dependent element shares a common ancestor** with
|
||||
the trigger, and use an ordinary swap. Example: put running totals in a
|
||||
sibling `<tbody>` so an amount edit can swap totals without replacing the
|
||||
amount input:
|
||||
```clojure
|
||||
;; totals live in their own tbody, a sibling of the input rows
|
||||
(com/data-grid- {:rows ...
|
||||
:footer-tbody [:tbody {:id "account-totals"} ...]})
|
||||
|
||||
;; the amount input swaps ONLY the totals tbody (never itself)
|
||||
[:input {:name "accounts[0][amount]"
|
||||
:hx-post "/transaction/edit-form-changed"
|
||||
:hx-target "#account-totals" :hx-select "#account-totals"
|
||||
:hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
|
||||
```
|
||||
|
||||
**Focus invariant (must always hold):** the input the user is typing in is never
|
||||
inside the region its own request swaps.
|
||||
|
||||
**Alpine components must survive swaps.** Null-guard every reference that depends
|
||||
on Alpine/tippy being initialised, and key a component by its server-provided
|
||||
value so a server-driven change re-initialises it instead of preserving stale
|
||||
state:
|
||||
```clojure
|
||||
;; null-guard:
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||
;; key by current value so morph/replace re-inits on server change:
|
||||
(assoc attrs :key (str id "--" current-value))
|
||||
```
|
||||
|
||||
**Selector strategy for targeted swaps (a consideration, not a mandate).**
|
||||
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
|
||||
— a unique `id` on every swappable element — gets noisy in repeated structures
|
||||
(e.g. a table of financial accounts where choosing an account must swap *that
|
||||
row's* dropdown). When you reach those advanced cases, consider a more
|
||||
consistent scheme instead of hand-minting ids everywhere:
|
||||
|
||||
- **Semantic markup + data-attributes** to craft a fine-grained selector without
|
||||
per-element ids. For example, mark rows/cells with their identity and target
|
||||
by attribute:
|
||||
```html
|
||||
<tr data-row="account" data-index="0">
|
||||
<td data-cell="account">
|
||||
<select hx-post="/transaction/edit-form-changed"
|
||||
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-swap="outerHTML" hx-trigger="changed">…</select>
|
||||
</td>
|
||||
<td data-cell="location">…</td>
|
||||
</tr>
|
||||
```
|
||||
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
|
||||
cursor path is, so the server and the markup agree on the target by
|
||||
construction rather than by convention. A render fn at form-path
|
||||
`[:accounts 0 :location]` would compute its own stable selector (id or
|
||||
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
|
||||
|
||||
The aim is *consistency and predictability* of swap targets in repeated/nested
|
||||
structures — pick whichever keeps targets unambiguous and easy to generate. Note
|
||||
this in `reference/swap-doctrine.md` and let the first modal that hits nested
|
||||
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
|
||||
|
||||
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||
|
||||
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||
down to here**, never faked to start at this depth.
|
||||
|
||||
```clojure
|
||||
;; GOOD: pure, works everywhere, testable without setup
|
||||
(defn account-row [{:keys [account index client-id amount-mode]}]
|
||||
(com/data-grid-row
|
||||
(com/hidden {:name (str "accounts[" index "][db/id]")
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:name (str "accounts[" index "][account]")
|
||||
:client-id client-id}))
|
||||
...))
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||
(defn account-rows [accounts-cursor]
|
||||
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||
```
|
||||
|
||||
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||
|
||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||
|
||||
- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate
|
||||
+ save). State is plain form fields + an entity id. No snapshot, no server
|
||||
state, no protocol.
|
||||
```clojure
|
||||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||
```
|
||||
|
||||
- **Genuinely multi-step → data-driven engine with session-stored step state.**
|
||||
|
||||
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
|
||||
> round-trip a serialized blob of the whole form through the page. Each step's
|
||||
> validated (cleaned) data is written to a **storage backend (the user session
|
||||
> by default)** under that step's key, and the steps are combined only at the
|
||||
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
|
||||
> EDN snapshot + piecewise merging with per-step form state stored in the
|
||||
> session.** A step writes its own data under its own key; nothing is merged
|
||||
> into a snapshot and nothing about other steps rides through the form.
|
||||
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
|
||||
> (`SessionStorage`), and `get_all_cleaned_data()`
|
||||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||
|
||||
A wizard is *data*:
|
||||
```clojure
|
||||
(def vendor-wizard-config
|
||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||
:next (fn [data] :terms)}
|
||||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||||
:next (fn [data] :done)}]
|
||||
:init-fn (fn [req] {...})
|
||||
:submit-route "/admin/vendor/wizard/submit"
|
||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||
```
|
||||
with a tiny engine (no protocols) whose state lives **in the session**, keyed
|
||||
by a wizard instance id, with each step's data stored under its own step key —
|
||||
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
|
||||
merge-into-snapshot:
|
||||
```clojure
|
||||
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
|
||||
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
|
||||
(defn create-wizard! [session config]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||
|
||||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||
(defn forget [session id] (update session :wizards dissoc id))
|
||||
|
||||
(defn render-wizard [{:keys [wizard-id config session request]}]
|
||||
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
|
||||
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
||||
[:form#wizard-form {:hx-post (:submit-route config)
|
||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
||||
;; only a reference token rides in the form -- not the form's state
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
||||
|
||||
;; Handlers thread the (possibly updated) session back into the Ring response.
|
||||
(defn handle-step-submit [config {:keys [session] :as request}]
|
||||
(let [{:strs [wizard-id current-step]} (:form-params request)
|
||||
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
||||
data (select-keys (:form-params request) (map name (:fields step)))]
|
||||
(if-let [errors (mc/explain (:schema step) data)]
|
||||
(-> (render-wizard {:wizard-id wizard-id :config config :session session
|
||||
:request (assoc request :errors errors)})
|
||||
html-response)
|
||||
(let [session' (put-step session wizard-id (keyword current-step) data)
|
||||
nxt ((:next step) data)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
|
||||
(assoc :session (forget session' wizard-id)))
|
||||
(let [session'' (set-step session' wizard-id nxt)]
|
||||
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
|
||||
:session session'' :request request}))
|
||||
(assoc :session session''))))))))
|
||||
```
|
||||
Two routes per wizard: open (`partial open-wizard config`) and submit
|
||||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
|
||||
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
|
||||
discarded on completion (`forget`). See Open decision 1 for the storage-backend
|
||||
choice (Ring session store vs. a durable store for long-lived wizards).
|
||||
|
||||
### 3.4 Selmer templates
|
||||
|
||||
Interactive components render from Selmer templates with plain-HTML attributes.
|
||||
Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a
|
||||
Selmer template embed Hiccup output (and vice versa) during the transition.
|
||||
|
||||
```html
|
||||
{# templates/components/typeahead.html #}
|
||||
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
|
||||
<a class="{{ classes }}" x-ref="input" tabindex="0"
|
||||
@keydown.down.prevent.stop="tippy?.show()"
|
||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||
<span x-text="value.label"></span>
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; render helper + interop bridge
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Principles
|
||||
|
||||
1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap
|
||||
doctrine live alongside the old code. Migrate one modal at a time behind its
|
||||
own route. Old machinery is deleted only when its last caller is gone.
|
||||
2. **Simplest first.** Each migration is small and reversible (one commit).
|
||||
Start with the already-proven modal, then the smallest fresh ones, and leave
|
||||
the largest/most complex for last — by which point the skill is mature.
|
||||
3. **Skill-driven and self-reinforcing.** After the first successful migration,
|
||||
distil the method into a skill (§5). Every subsequent migration *reads* the
|
||||
skill first and *extends* it last.
|
||||
4. **Quality must measurably improve.** Each migration records a scorecard (§6);
|
||||
no metric may regress for the touched modal.
|
||||
5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e
|
||||
suite must stay green after every migration.
|
||||
|
||||
---
|
||||
|
||||
## 5. The skill: `ssr-form-migration`
|
||||
|
||||
**When it is created:** in **Phase 1**, immediately after — and distilled from —
|
||||
the first successful modal migration (the transaction-edit modal, whose
|
||||
whole-form swap implementation already exists and serves as the reference). The
|
||||
skill is *not* written speculatively; it encodes a method that already worked.
|
||||
|
||||
**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project
|
||||
convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
.claude/skills/ssr-form-migration/
|
||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||
reference/
|
||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
|
||||
# target-selector strategy (semantic/data-attr/form-path->id)
|
||||
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||
gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
|
||||
test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
|
||||
scorecard.md # the §6 heuristics + a running table of every migration's numbers
|
||||
```
|
||||
|
||||
**Growth contract — the last task of every migration:**
|
||||
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||
- Hit a surprise? → one entry in `gotchas.md`.
|
||||
- Found a test pattern? → `test-recipes.md`.
|
||||
- Playbook step missing/wrong? → fix `SKILL.md`.
|
||||
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||
|
||||
**Success signal:** each migration should reuse more cookbook entries and start
|
||||
from a better scorecard baseline than the previous one. If migration N+1 is not
|
||||
easier than N, the skill-update step is being skipped — treat that as a bug.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality scorecard (the ratchet)
|
||||
|
||||
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each
|
||||
migration in the commit message and `scorecard.md`. **No metric may regress for
|
||||
the touched modal.**
|
||||
|
||||
| # | Heuristic | Measure | Target |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
||||
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
|
||||
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
|
||||
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
|
||||
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
|
||||
|
||||
These are directional evidence, not targets to game. Pair them with the e2e
|
||||
parity gate (§7) so "simpler" can never mean "broken."
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
Consistent with the project's `testing-conventions` skill (test user-observable
|
||||
behavior; assert DB state directly; don't test the means).
|
||||
|
||||
1. **Characterization e2e first.** Before changing a modal, write/confirm a
|
||||
Playwright spec capturing its current behavior — focus/caret survival across
|
||||
swaps, the field round-trip, validation errors, and the actual save. This
|
||||
spec is the parity contract the refactor must keep green.
|
||||
2. **Pure-function checks via REPL.** Once render fns are pure, exercise the
|
||||
data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned
|
||||
data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`)
|
||||
— this style survives the Selmer switch. Avoid brittle structural assertions.
|
||||
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by
|
||||
querying the DB, not by asserting on markup.
|
||||
|
||||
**Regression gate:** the full e2e suite must stay green after every migration.
|
||||
Record the current pass/fail baseline in `test-recipes.md` at the first
|
||||
migration and never drop below it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Per-migration playbook (the repeatable loop)
|
||||
|
||||
This is the canonical loop each modal phase follows; it lives in `SKILL.md`.
|
||||
Modal phases below list only what is *specific* to that modal plus this loop.
|
||||
|
||||
1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas.
|
||||
2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step →
|
||||
wizard (engine + server state). When in doubt, it's a form.
|
||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||
(heuristics 5, 8).
|
||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||
only for disjoint regions (heuristic 7).
|
||||
8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6).
|
||||
9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check
|
||||
pure fns. Re-measure scorecard — no regressions.
|
||||
10. [ ] **Commit** one reversible feature commit; message includes the scorecard
|
||||
delta and reused/new cookbook entries.
|
||||
11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard /
|
||||
SKILL.md). *Not optional.*
|
||||
|
||||
---
|
||||
|
||||
## 9. Phases & tasks
|
||||
|
||||
> Migration target inventory (verify line counts at execution time):
|
||||
|
||||
| Modal | File | Steps | Target | Phase |
|
||||
|-------|------|-------|--------|-------|
|
||||
| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) |
|
||||
| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 |
|
||||
| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 |
|
||||
| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 |
|
||||
| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 |
|
||||
| Invoice Pay | `invoices.clj` | 2 | wizard | 7 |
|
||||
| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 |
|
||||
| Vendor | `admin/vendors.clj` | 5 | wizard | 9 |
|
||||
| Client | `admin/clients.clj` | 7 | wizard | 10 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Distil the skill (no app code changes)
|
||||
|
||||
**Rationale:** the transaction-edit modal has already been migrated to the
|
||||
whole-form swap approach successfully. Capture that working method as a skill
|
||||
*now*, so every later migration is cheaper and consistent. (If the reference
|
||||
implementation is not yet on the working branch, merge it first — that is an
|
||||
acceptable prerequisite.)
|
||||
|
||||
- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8).
|
||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||
swaps as worked examples.
|
||||
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||
Phase 2."
|
||||
- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved
|
||||
(e.g. the hardened typeahead, the totals-in-sibling-`<tbody>` pattern).
|
||||
- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value).
|
||||
- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**.
|
||||
- [ ] Create `scorecard.md` with the §6 table and an empty results table.
|
||||
- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and
|
||||
understand the whole method without this plan.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Trial the skill on Transaction Edit (first test subject)
|
||||
|
||||
**Rationale:** validate the freshly written skill against the one modal whose
|
||||
"correct" outcome we already know. This is also where Selmer + pure functions
|
||||
are completed for this modal and the Selmer conventions get written from a real,
|
||||
verified example. Target type: **plain form** (single step with a mode toggle —
|
||||
the toggle is just a `GET` with a `?mode=` query param that re-renders the form).
|
||||
|
||||
**Foundation (do once, here):**
|
||||
- [ ] Add the `selmer` dependency to `project.clj`.
|
||||
- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge**
|
||||
(Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
|
||||
- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout,
|
||||
and a Hiccup component renders inside a Selmer template.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps
|
||||
focus; selecting an account updates only its Location options; changing vendor
|
||||
/ adding / removing a row / toggling mode / toggling $-vs-% re-renders the
|
||||
whole form correctly; amount edits update totals without losing the amount
|
||||
caret; save round-trips.
|
||||
- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`,
|
||||
`account-row`, `account-totals` (remove any `*-no-cursor*` duplicates).
|
||||
- [ ] Convert those render fns to Selmer templates; record each as a cookbook
|
||||
entry; finalize `selmer-conventions.md`.
|
||||
- [ ] Verify the swaps match the doctrine (whole-form for structural changes,
|
||||
targeted cell for account→location, sibling-`<tbody>` for totals, no request
|
||||
for memo); confirm `grep -c hx-swap-oob` is 0.
|
||||
- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST
|
||||
/transaction/edit`, plus the single `edit-form-changed` re-render endpoint.
|
||||
- [ ] Verify (modal e2e + full suite green; DB save asserted).
|
||||
- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the
|
||||
trial revealed; append the scorecard row (this is the baseline others beat).
|
||||
- [ ] **Exit criteria:** skill-driven migration reproduces the known-good
|
||||
behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Transaction Bulk Code (plain form)
|
||||
|
||||
**Rationale:** the smallest *fresh* modal — first real test of "read the skill,
|
||||
apply it cold." Single-step form currently wearing a wizard costume.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; delete the wizard protocol/record and snapshot.
|
||||
- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input.
|
||||
- [ ] Search params preserved as plain hidden fields (no EDN snapshot).
|
||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||
faked-cursor count all down vs. baseline.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Sales Summary Edit (plain form)
|
||||
|
||||
**Rationale:** another single-step form; reinforces the cold-apply loop.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`.
|
||||
- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries.
|
||||
- [ ] Collapse 3 wizard routes → 2.
|
||||
- [ ] Verify edit saves (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Invoice Bulk Edit (plain form with rows + totals)
|
||||
|
||||
**Rationale:** first single-step form with dynamic account rows and live totals
|
||||
— exercises the add-row endpoint and the totals-in-sibling-`<tbody>` swap
|
||||
(instead of OOB).
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals`
|
||||
cookbook entries from Phase 2.
|
||||
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
||||
sibling-`<tbody>` swap, **not** OOB.
|
||||
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
|
||||
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
|
||||
helper, rather than hand-minted ids per element. Record the chosen convention
|
||||
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
|
||||
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
||||
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
|
||||
confirmed reusable across two modals now.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
||||
|
||||
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
||||
right place to introduce the data-driven engine (§3.3) and **session-stored
|
||||
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
|
||||
merge.
|
||||
|
||||
**Engine (do once, here):**
|
||||
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
|
||||
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
|
||||
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
|
||||
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
|
||||
and concurrent wizards don't collide. Each fn returns the updated session for
|
||||
the handler to thread into the Ring response. Test the lifecycle via REPL.
|
||||
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
||||
`open-wizard`) — engine threads session through and only `wizard-id` rides in
|
||||
the form. Test render + step navigation + that no snapshot is emitted.
|
||||
- [ ] Document the engine usage and the formtools inspiration in
|
||||
`reference/form-vs-wizard.md`.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
||||
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
|
||||
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
|
||||
- [ ] Collapse routes → 2 (open, submit).
|
||||
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Invoice Pay (2-step wizard)
|
||||
|
||||
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
|
||||
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
|
||||
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
|
||||
`invoice-by-id` lookup); branch `:next` on payment method.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify each payment method path (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — New Invoice (3-step wizard)
|
||||
|
||||
**Rationale:** a true 3-step wizard with a conditional accounts step — the
|
||||
reference multi-step shape.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
|
||||
`render-submit-step`; reuse the expense-account row cookbook entry.
|
||||
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
|
||||
when not customizing.
|
||||
- [ ] `:init-fn` sets defaults (e.g. date = now).
|
||||
- [ ] Add-row for expense accounts via the sibling-`<tbody>` totals pattern.
|
||||
- [ ] Collapse routes → 2 (+1 add-row).
|
||||
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — Vendor (5-step wizard)
|
||||
|
||||
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
|
||||
`render-account-step`, `render-address-step`, `render-legal-step`.
|
||||
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
|
||||
(empty vs. loaded entity).
|
||||
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Client (7-step wizard) — largest, last
|
||||
|
||||
**Rationale:** the biggest, most complex modal (nested bank accounts, location
|
||||
matches, emails, contact methods). Deliberately last, when the skill is richest.
|
||||
|
||||
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
|
||||
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
|
||||
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
|
||||
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
|
||||
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
|
||||
drop `fc/with-field-default` nesting.
|
||||
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
|
||||
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
|
||||
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
|
||||
suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 11 — Cleanup
|
||||
|
||||
**Rationale:** remove the now-dead old machinery.
|
||||
|
||||
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
|
||||
remains; remove any v1→v2 shim.
|
||||
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
|
||||
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
|
||||
Hiccup, now that the skill makes it cheap.
|
||||
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
|
||||
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
|
||||
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
||||
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
||||
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
||||
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
|
||||
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
|
||||
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
|
||||
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions
|
||||
|
||||
1. **Wizard state storage** — store multi-step state in the **Ring session**
|
||||
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
|
||||
plain forms? Confirm the session backend in use (in-memory vs. durable) is
|
||||
acceptable for in-flight wizard lifetime, or pick a durable store for
|
||||
long-lived flows. *(recommended: session storage, scoped to multi-step
|
||||
wizards only)*
|
||||
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
||||
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
||||
Phase 11)*
|
||||
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
|
||||
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
|
||||
project default. *(recommended: yes)*
|
||||
4. **First step** — start by distilling the skill (Phase 1) with the reference
|
||||
implementation merged as a prerequisite, rather than treating the merge
|
||||
itself as step one. *(recommended: yes)*
|
||||
@@ -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 2–5 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.
|
||||
@@ -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 |
|
||||
@@ -455,6 +455,93 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
|
||||
// click a rendered result. The vendor search is backed by Solr (unavailable in
|
||||
// tests), so the result option is injected into the typeahead's Alpine
|
||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||
// search input firing a native `change` on blur, the `value = element` click
|
||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
||||
// regressed: a stale native `change` from the search input used to win the race
|
||||
// and revert the vendor to its previous value.
|
||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||
|
||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||
await typeahead.locator('a[x-ref="input"]').click();
|
||||
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
|
||||
// Type under the 3-char search threshold so no Solr request fires and clears
|
||||
// our injected option, while still dirtying the input so it fires a native
|
||||
// `change` on blur -- the event that used to clobber the selection.
|
||||
await search.fill('te');
|
||||
|
||||
// Inject a clickable result into the typeahead's Alpine state.
|
||||
await typeahead.evaluate(
|
||||
(el: HTMLElement, opt: { id: number; label: string }) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||
},
|
||||
{ id: vendorId, label: vendorName }
|
||||
);
|
||||
|
||||
// Click the rendered option: fires the search input's native change (stale
|
||||
// value) AND the synthetic change carrying the new value, then HTMX swaps.
|
||||
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
|
||||
|
||||
await page.waitForResponse(
|
||||
(response: any) =>
|
||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Opens the edit modal and activates the Manual tab, waiting on the vendor
|
||||
// typeahead rather than the account grid (which only exists in advanced mode).
|
||||
async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
|
||||
const editButton = page
|
||||
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
|
||||
.nth(transactionIndex);
|
||||
await editButton.click();
|
||||
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
}
|
||||
|
||||
test.describe('Transaction Edit Vendor Selection', () => {
|
||||
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
|
||||
await openManualVendorSection(page, 3);
|
||||
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId: number = testInfo.accounts.vendor;
|
||||
|
||||
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
|
||||
|
||||
// The displayed vendor label must reflect the selection after the HTMX
|
||||
// round-trip. Before the fix this reverted to blank because a stale
|
||||
// `change` event submitted the previous vendor and its response won.
|
||||
const label = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
await expect(label).toHaveText('Test Vendor');
|
||||
|
||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||
const hidden = page
|
||||
.locator(
|
||||
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
)
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(vendorId.toString());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Link Date Display', () => {
|
||||
test('should show payment date when linking to payment', async ({ page }) => {
|
||||
await openEditModalForTransaction(page, 'Transaction for payment link');
|
||||
|
||||
125
e2e/transaction-import.spec.ts
Normal file
125
e2e/transaction-import.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
||||
// TSV format from the master branch. Column order (14 columns), per
|
||||
// auto-ap.import.manual/columns:
|
||||
// 0:status 1:raw-date 2:description-original 3:high-level-category
|
||||
// 4,5:(unused) 6:amount 7..11:(unused) 12:bank-account-code 13:client-code
|
||||
//
|
||||
// The test server (auto-ap.test-server) seeds client "TEST" with a bank
|
||||
// account whose code is the deterministic "TEST-CHK" (see seed-test-data).
|
||||
|
||||
const IMPORT_PATH = '/transaction2/external-import-new';
|
||||
|
||||
function yodleeRow(opts: {
|
||||
status?: string;
|
||||
date?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
amount?: string;
|
||||
bankAccountCode?: string;
|
||||
clientCode?: string;
|
||||
}): string {
|
||||
const cols = new Array(14).fill('');
|
||||
cols[0] = opts.status ?? 'POSTED';
|
||||
cols[1] = opts.date ?? '';
|
||||
cols[2] = opts.description ?? '';
|
||||
cols[3] = opts.category ?? '';
|
||||
cols[6] = opts.amount ?? '';
|
||||
cols[12] = opts.bankAccountCode ?? '';
|
||||
cols[13] = opts.clientCode ?? '';
|
||||
return cols.join('\t');
|
||||
}
|
||||
|
||||
function yodleeTsv(rows: string[]): string {
|
||||
// First line is a header that the importer drops.
|
||||
const header = new Array(14).fill('');
|
||||
header[0] = 'Status';
|
||||
header[1] = 'Date';
|
||||
header[2] = 'Description';
|
||||
header[6] = 'Amount';
|
||||
header[12] = 'Bank Account';
|
||||
header[13] = 'Client';
|
||||
return [header.join('\t'), ...rows].join('\n');
|
||||
}
|
||||
|
||||
async function gotoImport(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto(IMPORT_PATH);
|
||||
}
|
||||
|
||||
async function pasteAndParse(page: any, tsv: string) {
|
||||
const textarea = page.locator('#parse-form textarea').first();
|
||||
await textarea.fill(tsv);
|
||||
// A visible "Parse" button submits the paste form (htmx swaps in the grid).
|
||||
await page.getByRole('button', { name: /parse/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
test.describe('Manual Transaction Import (SSR)', () => {
|
||||
test('renders the import page with a paste box', async ({ page }) => {
|
||||
await gotoImport(page);
|
||||
await expect(page.locator('#parse-form textarea').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('paste -> parse -> review grid -> import a valid transaction', async ({ page }) => {
|
||||
await gotoImport(page);
|
||||
|
||||
const description = 'E2E Imported Coffee';
|
||||
const tsv = yodleeTsv([
|
||||
yodleeRow({
|
||||
date: '01/15/2024',
|
||||
description,
|
||||
category: 'Food',
|
||||
amount: '12.50',
|
||||
bankAccountCode: 'TEST-CHK',
|
||||
clientCode: 'TEST',
|
||||
}),
|
||||
]);
|
||||
|
||||
await pasteAndParse(page, tsv);
|
||||
|
||||
// The review grid renders the parsed row as editable inputs (the
|
||||
// description lives in an input value, so assert on the input, not text).
|
||||
await expect(page.locator('input[value="TEST-CHK"]').first()).toBeVisible();
|
||||
await expect(page.locator(`input[value="${description}"]`).first()).toBeVisible();
|
||||
|
||||
// Import the clean batch.
|
||||
await page.getByRole('button', { name: /^import$/i }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The imported transaction shows up on the transactions list.
|
||||
await page.goto('/transaction2?date-range=all');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await expect(page.getByText(description)).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks the whole batch when a row has an unknown bank-account code', async ({ page }) => {
|
||||
await gotoImport(page);
|
||||
|
||||
const description = 'E2E Blocked Row';
|
||||
const tsv = yodleeTsv([
|
||||
yodleeRow({
|
||||
date: '01/16/2024',
|
||||
description,
|
||||
amount: '20.00',
|
||||
bankAccountCode: 'NOPE-DOES-NOT-EXIST',
|
||||
clientCode: 'TEST',
|
||||
}),
|
||||
]);
|
||||
|
||||
await pasteAndParse(page, tsv);
|
||||
|
||||
// The grid surfaces a blocking error for the bad row. The importer reuses
|
||||
// the master-branch message wording ("Cannot find bank account by code …").
|
||||
await expect(page.getByText(/cannot find bank account/i).first()).toBeVisible();
|
||||
|
||||
// Importing does not create the transaction (batch blocked).
|
||||
await page.getByRole('button', { name: /^import$/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.goto('/transaction2?date-range=all');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await expect(page.getByText(description)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -333,7 +333,8 @@
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
||||
|
||||
(mark-all-dirty 5)
|
||||
(mark-all-dirty 14)
|
||||
|
||||
(delete-all)
|
||||
|
||||
(sales-summaries-v2)
|
||||
|
||||
@@ -114,27 +114,17 @@
|
||||
{}
|
||||
line-items)
|
||||
total-line-amount (reduce + 0.0 (vals expense-accounts))
|
||||
accounts (if (zero? total-line-amount)
|
||||
[]
|
||||
(vec (for [[account amount] expense-accounts]
|
||||
(let [ratio (/ amount total-line-amount)
|
||||
cents (int (Math/round (* ratio abs-total 100)))]
|
||||
#:invoice-expense-account {:db/id (random-tempid)
|
||||
:account account
|
||||
:location (:invoice/location invoice)
|
||||
:amount (* 0.01 cents)}))))
|
||||
accounts (mapv
|
||||
(fn [a]
|
||||
(update a :invoice-expense-account/amount
|
||||
#(with-precision 2
|
||||
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
|
||||
accounts)
|
||||
leftover (with-precision 2 (.round (bigdec (- abs-total
|
||||
(reduce + 0.0 (map :invoice-expense-account/amount accounts))))
|
||||
*math-context*))
|
||||
accounts (if (seq accounts)
|
||||
(update-in accounts [(dec (count accounts)) :invoice-expense-account/amount] #(+ % (double leftover)))
|
||||
[])]
|
||||
leftover (- abs-total total-line-amount)
|
||||
food-cost-account (get-line-account nil)
|
||||
adjusted-accounts (if (zero? leftover)
|
||||
expense-accounts
|
||||
(update expense-accounts food-cost-account (fnil + 0.0) leftover))
|
||||
accounts (vec (for [[account amount] adjusted-accounts]
|
||||
#:invoice-expense-account {:db/id (random-tempid)
|
||||
:account account
|
||||
:location (:invoice/location invoice)
|
||||
:amount (with-precision 2
|
||||
(double (.setScale (bigdec amount) 2 java.math.RoundingMode/HALF_UP)))}))]
|
||||
(dissoc (assoc invoice :invoice/expense-accounts accounts) :line-items))))
|
||||
|
||||
(defn maybe-code-line-items [invoice]
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
(defn invoke-glimpse2 [f]
|
||||
(let [result (slurp (:payload (lambda/invoke {:client-config {:request-timeout 120000
|
||||
:socket-timeout 120000}} {:function-name "glimpse2" :payload
|
||||
:socket-timeout 120000}} {:function-name "glimpse2-test" :payload
|
||||
(json/write-str
|
||||
(alog/peek ::x {"url" (str "https://" "data.prod.app.integreatconsult.com" "/" f)}))})))]
|
||||
|
||||
|
||||
@@ -62,7 +62,17 @@
|
||||
(.setHandler server stats-handler))
|
||||
(.setStopAtShutdown server true))
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (str (or (env :port) "3000"))))
|
||||
(def ^:dynamic *http-port-override*
|
||||
;; In dev, `lein mcp-repl` records the chosen HTTP port in `.http-port` so it
|
||||
;; stays stable across reloads. `refresh` re-evaluates this def, so reading the
|
||||
;; file here (rather than relying solely on an alter-var-root override that gets
|
||||
;; reset) keeps the port from falling back to (env :port). Absent in prod.
|
||||
(let [f (java.io.File. ".http-port")]
|
||||
(when (.exists f)
|
||||
(let [p (.trim ^String (slurp f))]
|
||||
(when (seq p) p)))))
|
||||
|
||||
(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 +92,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
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[datomic.api :as dc]
|
||||
[hiccup2.core :as hiccup]))
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(defn hourly-changes []
|
||||
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
|
||||
@@ -56,34 +55,68 @@
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Growth in clients"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "client-chart"
|
||||
:data-chart (hx/json {:labels ["2 years ago" "1 year ago" "today"],
|
||||
:series [(for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Bar('#client-chart', JSON.parse(document.getElementById('client-chart').getAttribute('data-chart')))")]]]])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels ["2 years ago" "1 year ago" "today"]
|
||||
:data (for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Clients',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])
|
||||
|
||||
(com/content-card {:class "w-1/2"}
|
||||
[:div {:class "flex flex-col px-4 py-3 space-y-3"}
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Changes by hour"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "changes"
|
||||
:data-chart (hx/json {:labels (for [n (range -24 0)]
|
||||
(format "%d" n)),
|
||||
:series [(hourly-changes)]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Line('#changes', JSON.parse(document.getElementById('changes').getAttribute('data-chart')))")]]]])])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels (for [n (range -24 0)]
|
||||
(format "%d" n))
|
||||
:data (hourly-changes)})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Changes',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])])
|
||||
"Admin"))
|
||||
|
||||
(def key->handler
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -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)))
|
||||
@@ -80,9 +80,7 @@
|
||||
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
|
||||
(let [preserved (transaction-nav-params request)]
|
||||
(hu/url (bidi/path-for ssr-routes/only-routes route)
|
||||
#_(if (or (:start-date preserved) (:end-date preserved))
|
||||
preserved
|
||||
(merge default-params preserved)))))
|
||||
{:date-range "month"})))
|
||||
|
||||
(defn left-aside- [{:keys [nav page-specific]} & _]
|
||||
[:aside {:id "left-nav",
|
||||
@@ -306,6 +304,12 @@
|
||||
:hx-boost "true"
|
||||
:hx-include "#transaction-filters"}
|
||||
"Approved")
|
||||
(when (is-admin? (:identity request))
|
||||
(menu-button- {:href (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/external-import-page)
|
||||
:active? (= ::transaction-routes/external-import-page (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
"Import"))
|
||||
(when (can? (:identity request)
|
||||
{:subject :transaction :activity :insights})
|
||||
(menu-button- {:href (bidi/path-for ssr-routes/only-routes
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
[:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))})
|
||||
[:div {:class (:max-w params "max-w-screen-2xl")}
|
||||
(into
|
||||
[:div {:class "relative overflow-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
[:div {:class "relative overflow-auto shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
children)]])
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[clj-time.core :as t]
|
||||
[clj-time.periodic :as per]))
|
||||
|
||||
(defn date-range-field [{:keys [value id apply-button?]}]
|
||||
(defn date-range-field [{:keys [value id]}]
|
||||
[:div {:id id}
|
||||
(com/field {:label "Date Range"}
|
||||
[:div.space-y-4
|
||||
@@ -17,7 +17,7 @@
|
||||
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
|
||||
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
|
||||
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
|
||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start
|
||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
|
||||
(com/date-input {:name "start-date"
|
||||
:value (some-> (:start value)
|
||||
(atime/unparse-local atime/normal-date))
|
||||
@@ -31,9 +31,8 @@
|
||||
:placeholder "Date"
|
||||
:size :small
|
||||
:class "shrink date-filter-input"})
|
||||
(when apply-button?
|
||||
(but/button- {:color :secondary
|
||||
:size :small
|
||||
:type "button"
|
||||
"x-on:click" "$dispatch('datesApplied')"}
|
||||
"Apply"))]])])
|
||||
(but/button- {:color :secondary
|
||||
:size :small
|
||||
:type "button"
|
||||
"x-on:click" "$dispatch('datesApplied')"}
|
||||
"Apply")]])])
|
||||
|
||||
@@ -63,14 +63,14 @@
|
||||
:x-model (:x-model params)}
|
||||
(if (:disabled params)
|
||||
[:span {:x-text "value.label"}]
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
[:input (-> params
|
||||
(dissoc :class)
|
||||
(dissoc :value-fn)
|
||||
@@ -81,9 +81,9 @@
|
||||
|
||||
(assoc
|
||||
"x-ref" "hidden"
|
||||
:type "hidden"
|
||||
:type "hidden"
|
||||
":value" "value.value"
|
||||
:x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))]
|
||||
:x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
|
||||
[:div.flex.w-full.justify-items-stretch
|
||||
[:span.flex-grow.text-left {"x-text" "value.label"}]
|
||||
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
|
||||
@@ -93,71 +93,72 @@
|
||||
:x-tooltip "value.warning"} "!")]]])
|
||||
|
||||
[:template {:x-ref "dropdown"}
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
||||
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
||||
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
|
||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||
[:input {:type "text"
|
||||
[:input {:type "text"
|
||||
:autofocus true
|
||||
:class (-> (:class params)
|
||||
(or "")
|
||||
(hh/add-class default-input-classes)
|
||||
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||
"x-model" "search"
|
||||
"placeholder" (:placeholder params)
|
||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()"
|
||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
|
||||
:class (-> (:class params)
|
||||
(or "")
|
||||
(hh/add-class default-input-classes)
|
||||
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||
"x-model" "search"
|
||||
"placeholder" (:placeholder params)
|
||||
"@change.stop" ""
|
||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
|
||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
|
||||
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
||||
[:template {:x-for "(element, index) in elements"}
|
||||
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
|
||||
:href "#"
|
||||
":class" "active == index ? 'active' : ''"
|
||||
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
|
||||
:href "#"
|
||||
":class" "active == index ? 'active' : ''"
|
||||
|
||||
"@mouseover" "active = index"
|
||||
"@mouseout" "active = -1"
|
||||
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()"
|
||||
"x-html" "element.label"}]]]
|
||||
"@mouseover" "active = index"
|
||||
"@mouseout" "active = -1"
|
||||
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
|
||||
"x-html" "element.label"}]]]
|
||||
[:template {:x-if "elements.length == 0"}
|
||||
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
|
||||
"No results found"]]]]]])
|
||||
|
||||
(defn multi-typeahead-dropdown- [params]
|
||||
[:template {:x-ref "dropdown"}
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
|
||||
"@keydown.escape.prevent" "tippy.hide();"
|
||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
|
||||
"@keydown.escape.prevent" "tippy.hide();"
|
||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||
[:div {:class (-> "relative"
|
||||
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
|
||||
[:div {:class "absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"}
|
||||
[:svg {:class "w-4 h-4 text-gray-500 dark:text-gray-400", :aria-hidden "true", :xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 20 20"}
|
||||
[:path {:stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "2", :d "m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"}]]]
|
||||
[:input {:type "text"
|
||||
[:input {:type "text"
|
||||
:class (-> (:class params)
|
||||
(or "")
|
||||
(hh/add-class "block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500")
|
||||
(hh/add-class default-input-classes))
|
||||
"x-model" "search"
|
||||
"placeholder" (:placeholder params)
|
||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||
"x-model" "search"
|
||||
"placeholder" (:placeholder params)
|
||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||
"@keydown.enter.prevent.stop" "if ($data.elements[active]) { if (value.has($data.elements[active].value)) { value.delete($data.elements[active].value) } else {value.add($data.elements[active].value); lookup[$data.elements[active].value] = $data.elements[active].label} } "
|
||||
"x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]]
|
||||
"x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]]
|
||||
[:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "}
|
||||
[:template {:x-for "(element, index) in elements"}
|
||||
[:li {":style" "index == 0 && 'border: 0 !important;'"}
|
||||
[:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer"
|
||||
[:li {":style" "index == 0 && 'border: 0 !important;'"}
|
||||
[:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer"
|
||||
|
||||
:href "#"
|
||||
":class" (hx/json {"active" (hx/js-fn "active==index")
|
||||
:href "#"
|
||||
":class" (hx/json {"active" (hx/js-fn "active==index")
|
||||
"implied" (hx/js-fn "all_selected && index != 0")})
|
||||
"@mouseover" "active = index"
|
||||
"@mouseout" "active = -1"
|
||||
"@mouseover" "active = index"
|
||||
"@mouseout" "active = -1"
|
||||
"@click.prevent" "toggle(element)"}
|
||||
(checkbox- {":checked" "value.has(element.value) || all_selected"
|
||||
:class "group-[&.implied]:bg-green-200"})
|
||||
#_[:input {:type "checkbox"}]
|
||||
[:span {"x-html" "element.label"}]]]]
|
||||
[:span {"x-html" "element.label"}]]]]
|
||||
[:template {:x-if "elements.length == 0"}
|
||||
[:li {:class "px-4 pt-4 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs " "style" "border: 0 !important"}
|
||||
"No results found"]]]]])
|
||||
@@ -225,7 +226,7 @@
|
||||
:x-init (str "$watch('value', v => $dispatch('change')); ")
|
||||
:search ""
|
||||
:active -1
|
||||
:elements (cond-> [{:value "all" :label "All"}]
|
||||
:elements (cond-> [{:value "all" :label "All"}]
|
||||
(sequential? (:value params))
|
||||
(into (map (fn [v]
|
||||
{:value ((:value-fn params identity) v)
|
||||
@@ -237,24 +238,24 @@
|
||||
:x-init "value=new Set(value || []); "}
|
||||
(if (:disabled params)
|
||||
[:span {:x-text "value.label"}]
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
|
||||
"@keydown.down.prevent.stop" "tippy.show();"
|
||||
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
||||
:tabindex 0
|
||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||
:x-ref "input"}
|
||||
[:template {:x-for "v in Array.from(value.values())"}
|
||||
[:input (-> params
|
||||
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||
(assoc
|
||||
:type "hidden"
|
||||
:type "hidden"
|
||||
"x-bind:value" "v"))]]
|
||||
[:template {:x-if "value.size == 0"}
|
||||
[:input (-> params
|
||||
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||
(assoc :type "hidden"
|
||||
(assoc :type "hidden"
|
||||
:value ""))]]
|
||||
[:div.flex.w-full.justify-items-stretch
|
||||
(multi-typeahead-selected-pill- params)
|
||||
@@ -296,23 +297,23 @@
|
||||
|
||||
(defn money-input- [{:keys [size] :as params}]
|
||||
[:input
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(update :class hh/add-class "appearance-none text-right")
|
||||
(update :class #(str % (use-size size)))
|
||||
(assoc :type "number"
|
||||
:step "0.01")
|
||||
(dissoc :size))])
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(update :class hh/add-class "appearance-none text-right")
|
||||
(update :class #(str % (use-size size)))
|
||||
(assoc :type "number"
|
||||
:step "0.01")
|
||||
(dissoc :size))])
|
||||
|
||||
(defn int-input- [{:keys [size] :as params}]
|
||||
[:input
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(update :class hh/add-class "appearance-none text-right")
|
||||
(update :class #(str % (use-size size)))
|
||||
(assoc :type "number"
|
||||
:step "1")
|
||||
(dissoc :size))])
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(update :class hh/add-class "appearance-none text-right")
|
||||
(update :class #(str % (use-size size)))
|
||||
(assoc :type "number"
|
||||
:step "1")
|
||||
(dissoc :size))])
|
||||
|
||||
(defn date-input- [{:keys [size] :as params}]
|
||||
[:div.shrink {:x-data (hx/json {:value (:value params)
|
||||
@@ -321,40 +322,40 @@
|
||||
"x-effect" "console.log('changed to' +value)"
|
||||
"@change-date.camel" "$dispatch('change')"}
|
||||
[:input
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :x-model "value")
|
||||
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :x-model "value")
|
||||
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
|
||||
|
||||
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
|
||||
(assoc :type "text")
|
||||
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
|
||||
(assoc :type "text")
|
||||
|
||||
(assoc "autocomplete" "off")
|
||||
(assoc "@change" "value = $event.target.value;")
|
||||
(assoc "autocomplete" "off")
|
||||
(assoc "@change" "value = $event.target.value;")
|
||||
|
||||
(assoc "@keydown.escape" "tippy.hide(); ")
|
||||
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size))]
|
||||
(assoc "@keydown.escape" "tippy.hide(); ")
|
||||
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size))]
|
||||
[:template {:x-ref "tooltip"}
|
||||
|
||||
[:div.shrink
|
||||
[:div
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :type "text")
|
||||
(assoc :value (:value params))
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :type "text")
|
||||
(assoc :value (:value params))
|
||||
;; the data-date field has to be bound before the datepicker can be initialized
|
||||
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
||||
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
||||
(assoc ":data-date" "value")
|
||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
||||
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
||||
(assoc ":data-date" "value")
|
||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size :name :x-model :x-modelable))]]]])
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size :name :x-model :x-modelable))]]]])
|
||||
|
||||
(defn multi-calendar-input- [{:keys [size] :as params}]
|
||||
(let [value (str/join ", "
|
||||
@@ -368,21 +369,21 @@
|
||||
[:template {:x-for "v in value"}
|
||||
[:input {:type "hidden" :name (:name params) :x-model "v"}]]
|
||||
[:div
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :type "text")
|
||||
(assoc :value value)
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :type "text")
|
||||
(assoc :value value)
|
||||
;; the data-date field has to be bound before the datepicker can be initialized
|
||||
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
|
||||
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
|
||||
(assoc ":data-date" "Array.prototype.join.call(value, ', ')")
|
||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
|
||||
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
|
||||
(assoc ":data-date" "Array.prototype.join.call(value, ', ')")
|
||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size :name :x-model :x-modelable))]]))
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size :name :x-model :x-modelable))]]))
|
||||
|
||||
(defn calendar-input- [{:keys [size] :as params}]
|
||||
(let [value (:value params)]
|
||||
@@ -392,21 +393,21 @@
|
||||
:x-model (:x-model params)}
|
||||
[:input {:type "hidden" :name (:name params) :x-model "value"}]
|
||||
[:div
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :type "text")
|
||||
(assoc :value value)
|
||||
(-> params
|
||||
(update :class (fnil hh/add-class "") default-input-classes)
|
||||
(assoc :type "text")
|
||||
(assoc :value value)
|
||||
;; the data-date field has to be bound before the datepicker can be initialized
|
||||
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
||||
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
||||
(assoc ":data-date" "value")
|
||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
||||
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
||||
(assoc ":data-date" "value")
|
||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size :name :x-model :x-modelable))]]))
|
||||
(update :class #(str % (use-size size) " w-full"))
|
||||
(dissoc :size :name :x-model :x-modelable))]]))
|
||||
|
||||
(defn field-errors- [{:keys [source key]} & rest]
|
||||
(let [errors (:errors (cond-> (meta source)
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
[:div {:id "exact-match-id-tag"}]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/import-table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
[auto-ap.routes.transactions :as transaction-routes]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.date-range :as dr]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.grid-page-helper :as helper]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [clj-date-schema entity-id html-response ref->enum-schema
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
(defn exact-match-id* [request]
|
||||
(if (nat-int? (:exact-match-id (:query-params request)))
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"}
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
|
||||
(com/hidden {:name "exact-match-id"
|
||||
"x-model" "exact_match"})
|
||||
(com/pill {:color :primary}
|
||||
@@ -46,13 +46,14 @@
|
||||
[:div {:hx-trigger "clientSelected from:body"
|
||||
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
:hx-swap "outerHTML"
|
||||
:class "filter-trigger"}
|
||||
(when (:client request)
|
||||
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
|
||||
(:db/id (:bank-account (:query-params request))))]
|
||||
(com/field {:label "Bank Account"}
|
||||
(com/radio-card {:size :small
|
||||
:name "bank-account"
|
||||
(com/radio-card {:size :small
|
||||
:name "bank-account"
|
||||
:value (or (when bank-account-belongs-to-client?
|
||||
(:db/id (:bank-account (:query-params request))))
|
||||
"")
|
||||
@@ -60,90 +61,96 @@
|
||||
(into [{:value ""
|
||||
:content "All"}]
|
||||
(for [ba (:client/bank-accounts (:client request))]
|
||||
{:value (:db/id ba)
|
||||
{:value (:db/id ba)
|
||||
:content (:bank-account/name ba)}))}))))])
|
||||
|
||||
(defn bank-account-filter [request]
|
||||
(html-response (bank-account-filter* request)))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#ledger-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
[:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
(com/hidden {:name "status"
|
||||
:value (some-> (:status (:query-params request)) name)})
|
||||
[:fieldset.space-y-6
|
||||
(com/field {:label "Vendor"}
|
||||
(com/typeahead {:name "vendor"
|
||||
:id "vendor"
|
||||
(com/typeahead {:name "vendor"
|
||||
:id "vendor"
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value (:vendor (:query-params request))
|
||||
:value (:vendor (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn :vendor/name}))
|
||||
:content-fn :vendor/name
|
||||
:class "filter-trigger"}))
|
||||
(com/field {:label "Account"}
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
:url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
:value (:account (:query-params request))
|
||||
:value (:account (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
||||
(:db/id (:client request))))}))
|
||||
(:db/id (:client request))))
|
||||
:class "filter-trigger"}))
|
||||
|
||||
(bank-account-filter* request)
|
||||
|
||||
(date-range-field* request)
|
||||
(dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true})
|
||||
(com/field {:label "Invoice #"}
|
||||
(com/text-input {:name "invoice-number"
|
||||
:id "invoice-number"
|
||||
:class "hot-filter"
|
||||
:value (:invoice-number (:query-params request))
|
||||
(com/text-input {:name "invoice-number"
|
||||
:id "invoice-number"
|
||||
:class "hot-filter"
|
||||
:value (:invoice-number (:query-params request))
|
||||
:placeholder "e.g., ABC-456"
|
||||
:size :small}))
|
||||
:size :small}))
|
||||
|
||||
(com/field {:label "Account Code"}
|
||||
[:div.flex.space-x-4.items-baseline
|
||||
(com/int-input {:name "numeric-code-gte"
|
||||
:id "numeric-code-gte"
|
||||
(com/int-input {:name "numeric-code-gte"
|
||||
:id "numeric-code-gte"
|
||||
:hx-preserve "true"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-gte (:query-params request))
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-gte (:query-params request))
|
||||
:placeholder "40000"
|
||||
:size :small})
|
||||
:size :small})
|
||||
[:div.align-baseline
|
||||
"to"]
|
||||
(com/int-input {:name "numeric-code-lte"
|
||||
(com/int-input {:name "numeric-code-lte"
|
||||
:hx-preserve "true"
|
||||
:id "numeric-code-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-lte (:query-params request))
|
||||
:id "numeric-code-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-lte (:query-params request))
|
||||
:placeholder "50000"
|
||||
:size :small})])
|
||||
:size :small})])
|
||||
|
||||
(com/field {:label "Amount"}
|
||||
[:div.flex.space-x-4.items-baseline
|
||||
(com/money-input {:name "amount-gte"
|
||||
:id "amount-gte"
|
||||
(com/money-input {:name "amount-gte"
|
||||
:id "amount-gte"
|
||||
:hx-preserve "true"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-gte (:query-params request))
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-gte (:query-params request))
|
||||
:placeholder "0.01"
|
||||
:size :small})
|
||||
:size :small})
|
||||
[:div.align-baseline
|
||||
"to"]
|
||||
(com/money-input {:name "amount-lte"
|
||||
(com/money-input {:name "amount-lte"
|
||||
:hx-preserve "true"
|
||||
:id "amount-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-lte (:query-params request))
|
||||
:id "amount-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-lte (:query-params request))
|
||||
:placeholder "9999.34"
|
||||
:size :small})])
|
||||
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
||||
:size :small})])
|
||||
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
||||
(com/hidden {:name "only-unbalanced"
|
||||
":value" "onlyUnbalanced ? 'on' : ''"})
|
||||
(com/checkbox {:value (:only-unbalanced (:query-params request))
|
||||
:class "filter-trigger"
|
||||
:x-model "onlyUnbalanced"}
|
||||
"Show unbalanced")]
|
||||
(exact-match-id* request)]])
|
||||
@@ -184,12 +191,12 @@
|
||||
args query-params
|
||||
query
|
||||
(if (:exact-match-id args)
|
||||
{:query {:find '[?e]
|
||||
:in '[$ ?e [?c ...]]
|
||||
{:query {:find '[?e]
|
||||
:in '[$ ?e [?c ...]]
|
||||
:where '[[?e :journal-entry/client ?c]]}
|
||||
:args [db
|
||||
(:exact-match-id args)
|
||||
valid-clients]}
|
||||
:args [db
|
||||
(:exact-match-id args)
|
||||
valid-clients]}
|
||||
(cond-> {:query {:find []
|
||||
:in ['$ '[?clients ?start ?end]]
|
||||
:where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
|
||||
@@ -202,28 +209,28 @@
|
||||
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
|
||||
|
||||
(seq (:external-id-like args))
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
:where ['[?e :journal-entry/external-id ?external-id]
|
||||
'[(.contains ^String ?external-id ?external-id-like)]]}
|
||||
:args [(:external-id-like args)]})
|
||||
:args [(:external-id-like args)]})
|
||||
|
||||
(seq (:source args))
|
||||
(merge-query {:query {:in ['?source]
|
||||
(merge-query {:query {:in ['?source]
|
||||
:where ['[?e :journal-entry/source ?source]]}
|
||||
:args [(:source args)]})
|
||||
:args [(:source args)]})
|
||||
(:external? route-params)
|
||||
(merge-query {:query {:where ['[?e :journal-entry/external-id]]}})
|
||||
|
||||
(:vendor args)
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
:where ['[?e :journal-entry/vendor ?vendor-id]]}
|
||||
:args [(:db/id (:vendor args))]})
|
||||
:args [(:db/id (:vendor args))]})
|
||||
|
||||
(:invoice-number args)
|
||||
(merge-query {:query {:in ['?invoice-number]
|
||||
(merge-query {:query {:in ['?invoice-number]
|
||||
:where ['[?e :journal-entry/original-entity ?oe]
|
||||
'[?oe :invoice/invoice-number ?invoice-number]]}
|
||||
:args [(:invoice-number args)]})
|
||||
:args [(:invoice-number args)]})
|
||||
|
||||
(or (:numeric-code-lte args)
|
||||
(:numeric-code-gte args)
|
||||
@@ -235,77 +242,77 @@
|
||||
|
||||
(or (:numeric-code-gte args)
|
||||
(:numeric-code-lte args))
|
||||
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
||||
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
||||
:where ['[?li :journal-entry-line/account ?a]
|
||||
'(or-join [?a ?c]
|
||||
[?a :account/numeric-code ?c]
|
||||
[?a :bank-account/numeric-code ?c])
|
||||
'[(>= ?c ?from-numeric-code)]
|
||||
'[(<= ?c ?to-numeric-code)]]}
|
||||
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
||||
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
||||
|
||||
(seq (:numeric-code args))
|
||||
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
||||
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
||||
:where ['[?li :journal-entry-line/account ?a]
|
||||
'(or-join [?a ?c]
|
||||
[?a :account/numeric-code ?c]
|
||||
[?a :bank-account/numeric-code ?c])
|
||||
'[(>= ?c ?from-numeric-code)]
|
||||
'[(<= ?c ?to-numeric-code)]]}
|
||||
:args [(map (juxt :from :to) (:numeric-code args))]})
|
||||
:args [(map (juxt :from :to) (:numeric-code args))]})
|
||||
(seq (:account args))
|
||||
(merge-query {:query {:in ['?a3]
|
||||
(merge-query {:query {:in ['?a3]
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [(:db/id (:account args))]})
|
||||
:args [(:db/id (:account args))]})
|
||||
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(>= ?a ?amount-gte)]]}
|
||||
:args [(:amount-gte args)]})
|
||||
:args [(:amount-gte args)]})
|
||||
|
||||
(:amount-lte args)
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(<= ?a ?amount-lte)]]}
|
||||
:args [(:amount-lte args)]})
|
||||
:args [(:amount-lte args)]})
|
||||
|
||||
(:db/id (:bank-account args))
|
||||
(merge-query {:query {:in ['?a]
|
||||
(merge-query {:query {:in ['?a]
|
||||
:where ['[?li :journal-entry-line/account ?a]]}
|
||||
:args [(:db/id (:bank-account args))]})
|
||||
:args [(:db/id (:bank-account args))]})
|
||||
|
||||
(:account-id args)
|
||||
(merge-query {:query {:in ['?a2]
|
||||
(merge-query {:query {:in ['?a2]
|
||||
:where ['[?e :journal-entry/line-items ?li2]
|
||||
'[?li2 :journal-entry-line/account ?a2]]}
|
||||
:args [(:account-id args)]})
|
||||
:args [(:account-id args)]})
|
||||
|
||||
(not-empty (:location args))
|
||||
(merge-query {:query {:in ['?location]
|
||||
(merge-query {:query {:in ['?location]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:location args)]})
|
||||
:args [(:location args)]})
|
||||
|
||||
(not-empty (:locations args))
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:locations args)]})
|
||||
:args [(:locations args)]})
|
||||
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"date" ['[?e :journal-entry/date ?sort-date]]
|
||||
"vendor" '[(or-join [?e ?sort-vendor]
|
||||
(and
|
||||
[?e :journal-entry/vendor ?v]
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :journal-entry/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"date" ['[?e :journal-entry/date ?sort-date]]
|
||||
"vendor" '[(or-join [?e ?sort-vendor]
|
||||
(and
|
||||
[?e :journal-entry/vendor ?v]
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :journal-entry/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
||||
"external-id" ['[?e :journal-entry/external-id ?sort-external-id]]
|
||||
"source" '[(or-join [?e ?sort-source]
|
||||
[?e :journal-entry/source ?sort-source]
|
||||
(and [(missing? $ ?e :journal-entry/source)]
|
||||
[(ground "") ?sort-source]))]}
|
||||
"source" '[(or-join [?e ?sort-source]
|
||||
[?e :journal-entry/source ?sort-source]
|
||||
(and [(missing? $ ?e :journal-entry/source)]
|
||||
[(ground "") ?sort-source]))]}
|
||||
args)
|
||||
|
||||
true
|
||||
@@ -334,11 +341,11 @@
|
||||
:journal-entry/external-id
|
||||
:db/id
|
||||
[:journal-entry/date :xform clj-time.coerce/from-date]
|
||||
{:journal-entry/vendor [:vendor/name :db/id]
|
||||
{:journal-entry/vendor [:vendor/name :db/id]
|
||||
:journal-entry/original-entity [:invoice/invoice-number
|
||||
:invoice/source-url
|
||||
:transaction/description-original :db/id]
|
||||
:journal-entry/client [:client/name :client/code :db/id]
|
||||
:journal-entry/client [:client/name :client/code :db/id]
|
||||
:journal-entry/line-items [:journal-entry-line/debit
|
||||
:journal-entry-line/location
|
||||
:journal-entry-line/running-balance
|
||||
@@ -362,8 +369,8 @@
|
||||
(defn sum-outstanding [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/outstanding-balance ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
@@ -375,8 +382,8 @@
|
||||
(defn sum-total-amount [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/total ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
@@ -386,7 +393,7 @@
|
||||
0.0)))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count
|
||||
all-ids :all-ids} (fetch-ids db request)]
|
||||
|
||||
@@ -410,12 +417,12 @@
|
||||
(if account-name
|
||||
[:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel)
|
||||
(format "$%,.2f"))))}
|
||||
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
||||
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
||||
(:journal-entry-line/location jel) ": "
|
||||
(or (:account/numeric-code account) (:bank-account/numeric-code account))
|
||||
" - " account-name]]
|
||||
[:div.text-left (com/pill {:color :yellow} "Unassigned")])
|
||||
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
||||
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
||||
|
||||
(when-not (= 1 (count lines))
|
||||
[:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines
|
||||
@@ -443,9 +450,9 @@
|
||||
[:to nat-int?]]]]]
|
||||
[:numeric-code-gte {:optional true} [:maybe nat-int?]]
|
||||
[:numeric-code-lte {:optional true} [:maybe nat-int?]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
||||
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
||||
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
||||
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]]
|
||||
@@ -459,17 +466,20 @@
|
||||
[:maybe clj-date-schema]]]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
:check-boxes? true
|
||||
:check-box-warning? (fn [e]
|
||||
(some? (:invoice/scheduled-payment e)))
|
||||
:page-specific-nav filters
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
:fetch-page fetch-page
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true}) [1 :hx-swap-oob] true)
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
:query-schema query-schema
|
||||
:action-buttons (fn [request]
|
||||
[(when-not (:external? (:route-params request)) (com/button {:color :primary
|
||||
@@ -485,7 +495,7 @@
|
||||
:hx-confirm "Are you sure you want to void this invoice?"}
|
||||
svg/trash))
|
||||
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
@@ -497,14 +507,14 @@
|
||||
:db/id (:db/id entity))}
|
||||
svg/undo))])
|
||||
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]]
|
||||
:title (fn [r]
|
||||
(str
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
"Register"))
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]]
|
||||
:title (fn [r]
|
||||
(str
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
"Register"))
|
||||
:entity-name "register"
|
||||
:route ::route/table
|
||||
:route ::route/table
|
||||
:csv-route ::route/csv
|
||||
:break-table (fn [request entity]
|
||||
(cond
|
||||
@@ -521,102 +531,102 @@
|
||||
(for [je journal-entries
|
||||
jel (:journal-entry/line-items je)]
|
||||
(merge jel je)))
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(and (= (count (:clients args)) 1)
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(and (= (count (:clients args)) 1)
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
||||
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
||||
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
(-> e :journal-entry/alternate-description)))}
|
||||
{:key "source"
|
||||
:name "Source"
|
||||
:sort-key "source"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render :journal-entry/source
|
||||
:render-csv :journal-entry/source}
|
||||
{:key "external-id"
|
||||
:name "External Id"
|
||||
:sort-key "external-id"
|
||||
:class "max-w-[12rem]"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
||||
:render-csv :journal-entry/external-id}
|
||||
{:key "date"
|
||||
:sort-key "date"
|
||||
:name "Date"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "amount"
|
||||
:sort-key "amount"
|
||||
:name "Amount"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
||||
(-> % :journal-entry-line/account :bank-account/name))
|
||||
:render-for #{:csv}}
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/debit)
|
||||
:render-csv :journal-entry-line/debit}
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
||||
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
(-> e :journal-entry/alternate-description)))}
|
||||
{:key "source"
|
||||
:name "Source"
|
||||
:sort-key "source"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render :journal-entry/source
|
||||
:render-csv :journal-entry/source}
|
||||
{:key "external-id"
|
||||
:name "External Id"
|
||||
:sort-key "external-id"
|
||||
:class "max-w-[12rem]"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
||||
:render-csv :journal-entry/external-id}
|
||||
{:key "date"
|
||||
:sort-key "date"
|
||||
:name "Date"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "amount"
|
||||
:sort-key "amount"
|
||||
:name "Amount"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
||||
(-> % :journal-entry-line/account :bank-account/name))
|
||||
:render-for #{:csv}}
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/debit)
|
||||
:render-csv :journal-entry-line/debit}
|
||||
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/credit)
|
||||
:render-csv :journal-entry-line/credit}
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/credit)
|
||||
:render-csv :journal-entry-line/credit}
|
||||
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(cond-> []
|
||||
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::invoice-route/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
||||
(-> i :journal-entry/original-entity :invoice/source-url)
|
||||
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
||||
:color :secondary
|
||||
:content (str "File")}
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(cond-> []
|
||||
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::invoice-route/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
||||
(-> i :journal-entry/original-entity :invoice/source-url)
|
||||
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
||||
:color :secondary
|
||||
:content (str "File")}
|
||||
|
||||
(-> i :journal-entry/original-entity :transaction/description-original)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
||||
(-> i :journal-entry/memo)
|
||||
(conj {:color :secondary
|
||||
:content (str "Memo: " (:journal-entry/memo i))}))))
|
||||
:render-for #{:html}}]}))
|
||||
(-> i :journal-entry/original-entity :transaction/description-original)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
||||
(-> i :journal-entry/memo)
|
||||
(conj {:color :secondary
|
||||
:content (str "Memo: " (:journal-entry/memo i))}))))
|
||||
:render-for #{:html}}]}))
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
@@ -53,7 +53,7 @@
|
||||
[:div {:id "exact-match-id-tag"}]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#payment-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#payment-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [params]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-cash-drawer-shift-table)
|
||||
"hx-target" "#cash-drawer-shift-table"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-expected-deposit-table)
|
||||
"hx-target" "#expected-deposit-table"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
|
||||
default-grid-fields-schema)]))
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-refund-table)
|
||||
"hx-target" "#refund-table"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-sales-table)
|
||||
"hx-target" "#sales-table"
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
;; always should be fast
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-tender-table)
|
||||
"hx-target" "#tender-table"
|
||||
|
||||
@@ -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"}]])
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
grid-page query-schema
|
||||
wrap-status-from-source]]
|
||||
[auto-ap.ssr.transaction.edit :as edit :refer [transaction-account-row*]]
|
||||
[auto-ap.ssr.transaction.import :as t-import]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers entity-id html-response
|
||||
many-entity modal-response percentage ref->enum-schema
|
||||
@@ -101,6 +102,7 @@
|
||||
(def key->handler
|
||||
(merge edit/key->handler
|
||||
bulk-code/key->handler
|
||||
t-import/key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/page page
|
||||
::route/approved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/approved))
|
||||
|
||||
@@ -316,7 +316,7 @@
|
||||
:content (:bank-account/name ba)}))}))))])
|
||||
|
||||
(defn filters [request]
|
||||
[:form#transaction-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#transaction-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -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,56 @@
|
||||
(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-sync "this:replace"
|
||||
: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) "$")
|
||||
@@ -756,9 +883,13 @@
|
||||
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
|
||||
(mm/form-schema linear-wizard))
|
||||
|
||||
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
|
||||
(render-step [this {{:keys [snapshot step-params] :as multi-form-state} :multi-form-state :as request}]
|
||||
(let [tx-id (mm/get-mfs-field multi-form-state :db/id)
|
||||
tx (d-transactions/get-by-id tx-id)]
|
||||
tx (d-transactions/get-by-id tx-id)
|
||||
;; Preserve explicit mode choice from step-params; only fall back to
|
||||
;; row-count heuristic on initial load when no mode has been chosen.
|
||||
mode (keyword (or (:mode step-params)
|
||||
(name (manual-mode-initial snapshot))))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Transaction"]
|
||||
@@ -815,7 +946,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 +955,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* mode request)
|
||||
(fc/with-field :transaction/approval-status
|
||||
(com/validated-field
|
||||
{:label "Status"
|
||||
@@ -867,12 +977,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,30 +1434,78 @@
|
||||
(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)
|
||||
(get (:form-params request) "mode")
|
||||
"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)
|
||||
(->db-id (get step-params "transaction/vendor"))
|
||||
(: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)
|
||||
;; The form always submits an account row (even when empty with account=nil),
|
||||
;; so we check if any row has a meaningful account ID.
|
||||
has-meaningful-accounts? (some #(some? (:transaction-account/account %))
|
||||
existing-accounts)
|
||||
;; Simple mode: always populate vendor default (overwrite existing).
|
||||
;; Advanced mode: populate only when 0 rows OR 1 empty row.
|
||||
should-populate? (case mode
|
||||
:simple true
|
||||
:advanced (or (empty? existing-accounts)
|
||||
(and (= 1 (count existing-accounts))
|
||||
(not has-meaningful-accounts?))))
|
||||
default-account (when (and should-populate? 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)]
|
||||
(-> (if (and should-populate? 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)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
||||
(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 +1548,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]
|
||||
|
||||
300
src/clj/auto_ap/ssr/transaction/import.clj
Normal file
300
src/clj/auto_ap/ssr/transaction/import.clj
Normal file
@@ -0,0 +1,300 @@
|
||||
(ns auto-ap.ssr.transaction.import
|
||||
"SSR manual bank-transaction import. Mirrors the SSR ledger import
|
||||
(auto-ap.ssr.ledger) but accepts the exact master-branch Yodlee
|
||||
positional-column TSV and drives the existing
|
||||
auto-ap.import.transactions engine (via auto-ap.import.manual/import-batch)
|
||||
unchanged. Two-stage flow: paste -> editable review grid -> import."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.graphql.utils :refer [assert-admin]]
|
||||
[auto-ap.import.manual :as manual]
|
||||
[auto-ap.import.transactions :as t]
|
||||
[auto-ap.permissions :refer [wrap-must]]
|
||||
[auto-ap.routes.transactions :as route]
|
||||
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers html-response
|
||||
wrap-form-4xx-2 wrap-schema-decode wrap-schema-enforce]]
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.data.csv :as csv]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[malli.core :as mc]
|
||||
[slingshot.slingshot :refer [throw+]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Parsing (positional Yodlee columns, identical to master)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn tsv->rows
|
||||
"Decode a pasted tab-separated Yodlee export into a vector of raw column
|
||||
vectors. Drops the header row (like auto-ap.import.manual/tabulate-data) and
|
||||
skips blank lines. No-op when already decoded."
|
||||
[data]
|
||||
(if (string? data)
|
||||
(with-open [r (io/reader (char-array data))]
|
||||
(into []
|
||||
(comp (drop 1)
|
||||
(filter (fn [row] (some (fn [c] (seq (str/trim (or c "")))) row))))
|
||||
(csv/read-csv r :separator \tab)))
|
||||
data))
|
||||
|
||||
(defn vector->row
|
||||
"Map a raw column vector onto the master positional column keys."
|
||||
[t]
|
||||
(if (vector? t)
|
||||
(into {} (filter first (map vector manual/columns t)))
|
||||
t))
|
||||
|
||||
(def parse-form-schema
|
||||
(mc/schema
|
||||
[:map
|
||||
[:table {:min 1
|
||||
:error/message "Paste should contain at least one row to import"
|
||||
:decode/string tsv->rows}
|
||||
[:vector {:coerce? true}
|
||||
[:map {:decode/arbitrary vector->row}
|
||||
[:status {:optional true} [:maybe :string]]
|
||||
[:raw-date {:optional true} [:maybe :string]]
|
||||
[:description-original {:optional true} [:maybe :string]]
|
||||
[:high-level-category {:optional true} [:maybe :string]]
|
||||
[:amount {:optional true} [:maybe :string]]
|
||||
[:bank-account-code {:optional true} [:maybe :string]]
|
||||
[:client-code {:optional true} [:maybe :string]]]]]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Validation (two-tier, preserving every master validation)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- bank-account-code->client [db]
|
||||
(into {} (dc/q '[:find ?bac ?c
|
||||
:where
|
||||
[?c :client/bank-accounts ?ba]
|
||||
[?ba :bank-account/code ?bac]]
|
||||
db)))
|
||||
|
||||
(defn- bank-account-code->bank-account [db]
|
||||
(into {} (dc/q '[:find ?bac ?ba
|
||||
:where [?ba :bank-account/code ?bac]]
|
||||
db)))
|
||||
|
||||
(defn warn-message
|
||||
"Map a non-:import engine categorization to a [message :warn] pair, or nil
|
||||
when the row will import cleanly."
|
||||
[action]
|
||||
(case action
|
||||
:extant ["Already imported — skipped" :warn]
|
||||
:not-ready ["Not ready (before account start date, client locked, or not posted) — skipped" :warn]
|
||||
:suppressed ["Suppressed — skipped" :warn]
|
||||
nil))
|
||||
|
||||
(defn classify-table
|
||||
"Given parsed row maps, return {:form-errors {:table {idx [[msg status]...]}}
|
||||
:has-errors? bool}. Hard (fixable) errors come from
|
||||
manual/manual->transaction; warnings come from the engine's own
|
||||
categorize-transaction so the grid preview matches what the import will do."
|
||||
[rows]
|
||||
(let [db (dc/db conn)
|
||||
client-lookup (bank-account-code->client db)
|
||||
ba-lookup (bank-account-code->bank-account db)
|
||||
indexed (map-indexed
|
||||
(fn [i row]
|
||||
(assoc (manual/manual->transaction row ba-lookup client-lookup)
|
||||
::idx i))
|
||||
rows)
|
||||
with-ids (t/apply-synthetic-ids indexed)
|
||||
ba-cache (atom {})
|
||||
existing-cache (atom {})
|
||||
entries (->> with-ids
|
||||
(map (fn [txn]
|
||||
(let [idx (::idx txn)
|
||||
hard (mapv (fn [e] [(:info e) :error]) (:errors txn))
|
||||
warn (when (and (empty? hard)
|
||||
(:transaction/bank-account txn))
|
||||
(let [ba-id (:transaction/bank-account txn)
|
||||
ba (or (get @ba-cache ba-id)
|
||||
(get (swap! ba-cache assoc ba-id
|
||||
(dc/pull db t/bank-account-pull ba-id))
|
||||
ba-id))
|
||||
existing (or (get @existing-cache ba-id)
|
||||
(get (swap! existing-cache assoc ba-id
|
||||
(t/get-existing ba-id))
|
||||
ba-id))]
|
||||
(warn-message (t/categorize-transaction txn ba existing))))]
|
||||
[idx (cond-> hard warn (conj warn))])))
|
||||
(sort-by first))
|
||||
form-errors {:table (into {} (filter (fn [[_ errs]] (seq errs)) entries))}
|
||||
has-errors? (boolean (some (fn [[_ errs]] (some (fn [[_ s]] (= :error s)) errs)) entries))]
|
||||
{:form-errors form-errors
|
||||
:has-errors? has-errors?}))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Views
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- row-badge [errors]
|
||||
(when (seq errors)
|
||||
[:div.p-1.flex.flex-col.gap-1
|
||||
(for [[m s] errors]
|
||||
[:div.text-xs {:class (if (= :error s) "text-red-600" "text-yellow-600")} m])]))
|
||||
|
||||
(defn- parsed-banner [request]
|
||||
(let [errs (->> (:form-errors request) :table vals (mapcat identity))
|
||||
n-err (count (filter (fn [[_ s]] (= :error s)) errs))
|
||||
n-warn (count (filter (fn [[_ s]] (= :warn s)) errs))
|
||||
n-rows (count (:table (:form-params request)))]
|
||||
[:div.bg-green-50.text-green-700.rounded.p-3.my-2
|
||||
(format "%,d rows parsed. " n-rows)
|
||||
(when (pos? n-err)
|
||||
[:span.text-red-700.font-semibold (format "%d error(s) must be fixed. " n-err)])
|
||||
(when (pos? n-warn)
|
||||
[:span.text-yellow-700.font-semibold (format "%d warning row(s) will be skipped. " n-warn)])]))
|
||||
|
||||
(defn external-import-text-form* [request]
|
||||
(fc/start-form
|
||||
(or (:form-params request) {}) (:form-errors request)
|
||||
[:form#parse-form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-parse)
|
||||
:hx-target "#forms"
|
||||
:hx-swap "outerHTML"}
|
||||
(fc/with-field :table
|
||||
[:div.flex.flex-col.gap-2
|
||||
(com/errors {:errors (when (string? (fc/field-errors)) (fc/field-errors))})
|
||||
(com/text-area {:name (fc/field-name)
|
||||
:rows 6
|
||||
:class "w-full font-mono text-xs"
|
||||
:placeholder "Paste your Yodlee transaction export (tab-separated, including the header row) here"})])
|
||||
(com/button {:color :primary :type "submit"} "Parse")]))
|
||||
|
||||
(defn external-import-table-form* [request]
|
||||
(fc/start-form
|
||||
(:form-params request) (:form-errors request)
|
||||
(fc/with-field :table
|
||||
(when (seq (fc/field-value))
|
||||
[:div.mt-4 {:x-data (hx/json {"showTable" true})}
|
||||
(when (:just-parsed? request)
|
||||
(parsed-banner request))
|
||||
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
||||
:hx-target "#forms"
|
||||
:hx-swap "outerHTML"
|
||||
:autocomplete "off"}
|
||||
[:div.flex.gap-4.items-center.my-2
|
||||
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
|
||||
(com/button {:color :primary :type "submit"} "Import")]
|
||||
[:div {:x-show "showTable"}
|
||||
(com/data-grid-card
|
||||
{:id "transaction-import-data"
|
||||
:route nil
|
||||
:title "Transactions to import"
|
||||
:paginate? false
|
||||
:headers [(com/data-grid-header {} "Date")
|
||||
(com/data-grid-header {} "Description")
|
||||
(com/data-grid-header {} "Amount")
|
||||
(com/data-grid-header {} "Bank Account")
|
||||
(com/data-grid-header {} "Client")
|
||||
(com/data-grid-header {} "Status")
|
||||
(com/data-grid-header {} "")]
|
||||
:rows
|
||||
(fc/cursor-map
|
||||
(fn [_]
|
||||
(let [row-errors (fc/field-errors)]
|
||||
(com/data-grid-row
|
||||
{}
|
||||
(com/data-grid-cell {} (fc/with-field :raw-date
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :description-original
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name)})))
|
||||
(com/data-grid-cell {} (fc/with-field :amount
|
||||
(com/money-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :bank-account-code
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :client-code
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-24"})))
|
||||
(com/data-grid-cell {} [:span.text-xs.text-gray-500 (fc/with-field :status (fc/field-value))])
|
||||
(com/data-grid-cell {:class "align-top"} (row-badge row-errors))))))}
|
||||
nil)]]]))))
|
||||
|
||||
(defn external-import-form* [request]
|
||||
[:div#forms
|
||||
(external-import-text-form* request)
|
||||
(external-import-table-form* request)])
|
||||
|
||||
(defn external-import-page [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/main-aside-nav
|
||||
:client-selection (:client-selection request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:request request}
|
||||
(com/breadcrumbs {}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Transactions"]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/external-import-page)} "Import"])
|
||||
(external-import-form* request))
|
||||
"Import Transactions"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Handlers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn external-import-parse [request]
|
||||
(let [{:keys [form-errors]} (classify-table (:table (:form-params request)))]
|
||||
(html-response
|
||||
(external-import-form* (assoc request :form-errors form-errors :just-parsed? true)))))
|
||||
|
||||
(defn import-transactions
|
||||
"Validate the (possibly edited) rows. Block the whole batch when any hard
|
||||
error remains; otherwise run the existing import engine on the rows. Returns
|
||||
the engine stats."
|
||||
[request]
|
||||
(assert-admin (:identity request))
|
||||
(let [rows (:table (:form-params request))
|
||||
{:keys [form-errors has-errors?]} (classify-table rows)]
|
||||
(when has-errors?
|
||||
(throw+ {:type :field-validation
|
||||
:form-errors form-errors
|
||||
:form-params (:form-params request)}))
|
||||
(let [user (or (:user/name (:identity request))
|
||||
(:user (:identity request))
|
||||
"SSR import")]
|
||||
(manual/import-batch rows user))))
|
||||
|
||||
(defn external-import-import [request]
|
||||
(let [stats (import-transactions request)
|
||||
imported (:import-batch/imported stats 0)
|
||||
extant (:import-batch/extant stats 0)
|
||||
not-ready (:import-batch/not-ready stats 0)
|
||||
errored (+ (:import-batch/error stats 0) (:failed-validation stats 0))]
|
||||
(html-response
|
||||
(external-import-form* (assoc request :form-params {} :form-errors {}))
|
||||
:headers {"hx-trigger"
|
||||
(hx/json {"notification"
|
||||
(format "%d imported, %d already imported, %d not ready, %d errored."
|
||||
imported extant not-ready errored)})})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Routing
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/external-import-page external-import-page
|
||||
::route/external-import-parse (-> external-import-parse
|
||||
(wrap-schema-enforce :form-schema parse-form-schema)
|
||||
(wrap-form-4xx-2 external-import-parse)
|
||||
(wrap-schema-decode :form-schema parse-form-schema))
|
||||
::route/external-import-import (-> external-import-import
|
||||
(wrap-schema-enforce :form-schema parse-form-schema)
|
||||
(wrap-form-4xx-2 external-import-parse)
|
||||
(wrap-nested-form-params))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-must {:activity :import :subject :transaction})
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
@@ -23,8 +23,6 @@
|
||||
[:title (str "Integreat | " page-name)]
|
||||
[:link {:href "/css/font.min.css", :rel "stylesheet"}]
|
||||
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
|
||||
[:link {:rel "stylesheet" :href "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.css"}]
|
||||
[:script {:src "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.js"}]
|
||||
[:link {:rel "stylesheet", :href "/output.css"}]
|
||||
[:script {:src "https://cdn.plaid.com/link/v2/stable/link-initialize.js"}]
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
|
||||
|
||||
46
src/clj/dev_mcp.clj
Normal file
46
src/clj/dev_mcp.clj
Normal file
@@ -0,0 +1,46 @@
|
||||
(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- read-port [path]
|
||||
"Read a previously-recorded port from `path`, or nil if missing/unparseable."
|
||||
(let [f (io/file path)]
|
||||
(when (.exists f)
|
||||
(try (Integer/parseInt (.trim ^String (slurp f)))
|
||||
(catch Exception _ nil)))))
|
||||
|
||||
(defn- stable-port [path]
|
||||
"Reuse the port recorded in `path` if present, otherwise pick a random
|
||||
available one. Always (re)writes the file so the port stays stable for this
|
||||
worktree across REPL restarts and reloads."
|
||||
(let [port (or (read-port path) (available-port))]
|
||||
(spit path (str port))
|
||||
port))
|
||||
|
||||
(defn- mcp-repl-task [& _args]
|
||||
"Start nREPL server and HTTP server.
|
||||
|
||||
Reuses the ports recorded in nrepl-port and .http-port if present (keeping
|
||||
them stable per worktree), otherwise picks random available ports and records
|
||||
them. Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
||||
(let [nrepl-port (stable-port "nrepl-port")
|
||||
http-port (stable-port ".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))
|
||||
105
src/clj/user.clj
105
src/clj/user.clj
@@ -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 ".")
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
(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}}
|
||||
"/new" {:get ::new
|
||||
:post ::new-submit
|
||||
"/location-select" ::location-select
|
||||
@@ -22,9 +22,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 +34,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
|
||||
|
||||
1319
test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj
Normal file
1319
test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj
Normal file
File diff suppressed because it is too large
Load Diff
164
test/clj/auto_ap/ssr/transaction/import_test.clj
Normal file
164
test/clj/auto_ap/ssr/transaction/import_test.clj
Normal file
@@ -0,0 +1,164 @@
|
||||
(ns auto-ap.ssr.transaction.import-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [admin-token setup-test-data test-bank-account
|
||||
test-client wrap-setup]]
|
||||
[auto-ap.ssr.transaction.import :as sut]
|
||||
[auto-ap.ssr.utils :refer [main-transformer]]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]
|
||||
[malli.core :as mc]
|
||||
[slingshot.slingshot :refer [try+]]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
(defn- seed-client! []
|
||||
(setup-test-data
|
||||
[(test-client :db/id "import-client"
|
||||
:client/code "TEST"
|
||||
:client/locations ["DT"]
|
||||
:client/bank-accounts [(test-bank-account :db/id "import-ba"
|
||||
:bank-account/code "TEST-CHK")])]))
|
||||
|
||||
(defn- txn-count []
|
||||
(or (dc/q '[:find (count ?e) . :where [?e :transaction/id]] (dc/db conn)) 0))
|
||||
|
||||
(defn- import! [rows]
|
||||
(sut/import-transactions {:form-params {:table rows} :identity (admin-token)}))
|
||||
|
||||
;; =============================================================================
|
||||
;; Pure parsing — tsv->rows, vector->row, parse-form-schema
|
||||
;; =============================================================================
|
||||
|
||||
(deftest tsv->rows-test
|
||||
(testing "Drops the header row and parses tab-separated columns"
|
||||
(let [tsv "Status\tDate\tDescription\nPOSTED\t01/15/2024\tCoffee"
|
||||
rows (sut/tsv->rows tsv)]
|
||||
(is (= 1 (count rows)))
|
||||
(is (= ["POSTED" "01/15/2024" "Coffee"] (first rows)))))
|
||||
(testing "Skips blank lines"
|
||||
(is (= 1 (count (sut/tsv->rows "h1\th2\nPOSTED\tx\n\t\n")))))
|
||||
(testing "No-op on already-decoded data"
|
||||
(is (= [{:raw-date "x"}] (sut/tsv->rows [{:raw-date "x"}])))))
|
||||
|
||||
(deftest vector->row-test
|
||||
(testing "Maps the exact master positional columns"
|
||||
(let [row (sut/vector->row
|
||||
["POSTED" "01/15/2024" "Coffee" "Food" "" "" "12.50" "" "" "" "" "" "TEST-CHK" "TEST"])]
|
||||
(is (= "POSTED" (:status row)))
|
||||
(is (= "01/15/2024" (:raw-date row)))
|
||||
(is (= "Coffee" (:description-original row)))
|
||||
(is (= "12.50" (:amount row)))
|
||||
(is (= "TEST-CHK" (:bank-account-code row)))
|
||||
(is (= "TEST" (:client-code row))))))
|
||||
|
||||
(deftest parse-form-schema-test
|
||||
(testing "Decodes a pasted Yodlee TSV string into row maps"
|
||||
(let [tsv (str "Status\tDate\tDescription\t\t\t\tAmount\t\t\t\t\t\tBank\tClient\n"
|
||||
"POSTED\t01/15/2024\tCoffee\tFood\t\t\t12.50\t\t\t\t\t\tTEST-CHK\tTEST")
|
||||
decoded (mc/decode sut/parse-form-schema {:table tsv} main-transformer)]
|
||||
(is (= 1 (count (:table decoded))))
|
||||
(is (= "TEST-CHK" (:bank-account-code (first (:table decoded))))))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Validation — classify-table (hard errors + warnings, preserving master)
|
||||
;; =============================================================================
|
||||
|
||||
(deftest classify-hard-errors-test
|
||||
(seed-client!)
|
||||
(testing "Unknown bank-account code is a hard error"
|
||||
(let [{:keys [form-errors has-errors?]}
|
||||
(sut/classify-table [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "NOPE"}])]
|
||||
(is has-errors?)
|
||||
(is (some (fn [[m _]] (re-find #"bank account" m)) (get-in form-errors [:table 0])))))
|
||||
|
||||
(testing "Unknown client fires independently when the bank account exists but is linked to no client"
|
||||
@(dc/transact conn [{:db/id "orphan-ba"
|
||||
:bank-account/code "ORPHAN-CHK"
|
||||
:bank-account/type :bank-account-type/check}])
|
||||
(let [{:keys [form-errors has-errors?]}
|
||||
(sut/classify-table [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "ORPHAN-CHK"}])
|
||||
msgs (map first (get-in form-errors [:table 0]))]
|
||||
(is has-errors?)
|
||||
(is (some #(re-find #"Cannot find client" %) msgs)
|
||||
"client-not-found error fires")
|
||||
(is (not (some #(re-find #"bank account by code" %) msgs))
|
||||
"bank-account-not-found error does not fire because the bank account exists")))
|
||||
|
||||
(testing "Invalid date is a hard error"
|
||||
(let [{:keys [form-errors has-errors?]}
|
||||
(sut/classify-table [{:raw-date "not-a-date" :amount "1.00" :bank-account-code "TEST-CHK"}])]
|
||||
(is has-errors?)
|
||||
(is (some (fn [[m _]] (re-find #"(?i)mm/dd/yyyy|date" m)) (get-in form-errors [:table 0]))))))
|
||||
|
||||
(deftest classify-clean-test
|
||||
(seed-client!)
|
||||
(testing "A fully valid row produces no errors or warnings"
|
||||
(let [{:keys [form-errors has-errors?]}
|
||||
(sut/classify-table [{:raw-date "01/15/2024" :description-original "Coffee"
|
||||
:amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
|
||||
(is (not has-errors?))
|
||||
(is (empty? (get-in form-errors [:table 0]))))))
|
||||
|
||||
(deftest classify-not-ready-warning-test
|
||||
(testing "A date before the bank-account start-date is a (skippable) warning, not an error"
|
||||
(setup-test-data
|
||||
[(test-client :db/id "import-client"
|
||||
:client/code "TEST"
|
||||
:client/locations ["DT"]
|
||||
:client/bank-accounts [(test-bank-account :db/id "import-ba"
|
||||
:bank-account/code "TEST-CHK"
|
||||
:bank-account/start-date #inst "2030-01-01")])])
|
||||
(let [{:keys [form-errors has-errors?]}
|
||||
(sut/classify-table [{:raw-date "01/15/2024" :description-original "Early"
|
||||
:amount "5.00" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
|
||||
(is (not has-errors?) "warnings do not block")
|
||||
(is (some (fn [[_ s]] (= :warn s)) (get-in form-errors [:table 0]))))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Import flow — import-transactions (engine reuse, block, idempotency, skip)
|
||||
;; =============================================================================
|
||||
|
||||
(deftest import-clean-test
|
||||
(seed-client!)
|
||||
(testing "Clean rows import via the engine and persist"
|
||||
(let [stats (import! [{:raw-date "01/15/2024" :description-original "Coffee"
|
||||
:amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
|
||||
(is (= 1 (:import-batch/imported stats)))
|
||||
(is (= 1 (txn-count))))))
|
||||
|
||||
(deftest import-blocks-on-hard-error-test
|
||||
(seed-client!)
|
||||
(testing "Any hard error blocks the whole batch — nothing is written"
|
||||
(is (= :blocked
|
||||
(try+
|
||||
(import! [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "NOPE"}])
|
||||
:did-not-throw
|
||||
(catch [:type :field-validation] _ :blocked))))
|
||||
(is (= 0 (txn-count)))))
|
||||
|
||||
(deftest import-idempotent-test
|
||||
(seed-client!)
|
||||
(testing "Re-importing the same paste is idempotent (extant), no duplicates"
|
||||
(let [row [{:raw-date "01/15/2024" :description-original "Coffee"
|
||||
:amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}]]
|
||||
(import! row)
|
||||
(let [stats (import! row)]
|
||||
(is (= 0 (:import-batch/imported stats)))
|
||||
(is (= 1 (:import-batch/extant stats)))
|
||||
(is (= 1 (txn-count)))))))
|
||||
|
||||
(deftest import-skips-warning-rows-test
|
||||
(testing "Warning rows (not-ready) are skipped, not imported, without blocking"
|
||||
(setup-test-data
|
||||
[(test-client :db/id "import-client"
|
||||
:client/code "TEST"
|
||||
:client/locations ["DT"]
|
||||
:client/bank-accounts [(test-bank-account :db/id "import-ba"
|
||||
:bank-account/code "TEST-CHK"
|
||||
:bank-account/start-date #inst "2030-01-01")])])
|
||||
(let [stats (import! [{:raw-date "01/15/2024" :description-original "Early"
|
||||
:amount "5.00" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
|
||||
(is (= 0 (:import-batch/imported stats)))
|
||||
(is (= 1 (:import-batch/not-ready stats)))
|
||||
(is (= 0 (txn-count))))))
|
||||
@@ -67,7 +67,7 @@
|
||||
[(assoc (test-client :db/id "client-id"
|
||||
:client/code "TEST"
|
||||
:client/locations ["DT"])
|
||||
:client/bank-accounts [(test-bank-account :db/id "bank-account-id")])
|
||||
:client/bank-accounts [(test-bank-account :db/id "bank-account-id" :bank-account/code "TEST-CHK")])
|
||||
(test-client :db/id "client-id-2"
|
||||
:client/code "TEST2"
|
||||
:client/locations ["NY"])
|
||||
@@ -135,19 +135,19 @@
|
||||
:payment/status :payment-status/pending
|
||||
:payment/date #inst "2023-06-15")
|
||||
;; Transaction and unpaid invoice for link testing
|
||||
(test-transaction :db/id "transaction-id-unpaid"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount -150.0
|
||||
:transaction/description-original "Transaction for unpaid invoice link"
|
||||
:transaction/approval-status :transaction-approval-status/unapproved)
|
||||
(test-transaction :db/id "transaction-id-feedback"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount 400.0
|
||||
:transaction/description-original "Transaction for feedback review"
|
||||
:transaction/approval-status :transaction-approval-status/requires-feedback)
|
||||
(test-invoice :db/id "invoice-unpaid-id"
|
||||
(test-transaction :db/id "transaction-id-unpaid"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount -150.0
|
||||
:transaction/description-original "Transaction for unpaid invoice link"
|
||||
:transaction/approval-status :transaction-approval-status/unapproved)
|
||||
(test-transaction :db/id "transaction-id-feedback"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount 400.0
|
||||
:transaction/description-original "Transaction for feedback review"
|
||||
:transaction/approval-status :transaction-approval-status/requires-feedback)
|
||||
(test-invoice :db/id "invoice-unpaid-id"
|
||||
:invoice/client "client-id"
|
||||
:invoice/vendor "vendor-id"
|
||||
:invoice/total 150.0
|
||||
|
||||
0
tmp/.gitkeep
Normal file
0
tmp/.gitkeep
Normal file
Reference in New Issue
Block a user