Compare commits
8 Commits
3441ae63b4
...
b6649a3d1d
| Author | SHA1 | Date | |
|---|---|---|---|
| b6649a3d1d | |||
| 38ae6f460f | |||
| e156d8bfd8 | |||
| 5c2cf8a631 | |||
| b8a0e9c3dc | |||
| 9659164fdc | |||
| 8f0a474fa8 | |||
| 6814cf1b15 |
1
.claude/skills/agent-browser
Symbolic link
1
.claude/skills/agent-browser
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/agent-browser
|
||||||
@@ -34,6 +34,14 @@ INTEGREAT_JOB="" lein run # Default: port 3000
|
|||||||
PORT=3449 lein run
|
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
|
## Test Execution
|
||||||
prefer using clojure-eval skill
|
prefer using clojure-eval skill
|
||||||
|
|
||||||
|
|||||||
125
CLAUDE.md
125
CLAUDE.md
@@ -1,125 +1,2 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
@AGENTS.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.
|
|
||||||
|
|||||||
@@ -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.describe('Transaction Link Date Display', () => {
|
||||||
test('should show payment date when linking to payment', async ({ page }) => {
|
test('should show payment date when linking to payment', async ({ page }) => {
|
||||||
await openEditModalForTransaction(page, 'Transaction for payment link');
|
await openEditModalForTransaction(page, 'Transaction for payment link');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -66,9 +66,9 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
(defn not-found [_]
|
(defn not-found [_]
|
||||||
{:status 404
|
{:status 404
|
||||||
:headers {}
|
:headers {}
|
||||||
:body ""})
|
:body ""})
|
||||||
|
|
||||||
(defn home-handler [{:keys [identity]}]
|
(defn home-handler [{:keys [identity]}]
|
||||||
(if identity
|
(if identity
|
||||||
@@ -125,13 +125,13 @@
|
|||||||
|
|
||||||
(defn wrap-logging [handler]
|
(defn wrap-logging [handler]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(mu/with-context (cond-> {:uri (:uri request)
|
(mu/with-context (cond-> {:uri (:uri request)
|
||||||
:route (:handler (bidi.bidi/match-route all-routes
|
:route (:handler (bidi.bidi/match-route all-routes
|
||||||
(:uri request)
|
(:uri request)
|
||||||
:request-method (:request-method request)))
|
:request-method (:request-method request)))
|
||||||
|
|
||||||
:client-selection (:client-selection request)
|
:client-selection (:client-selection request)
|
||||||
:source "request"
|
:source "request"
|
||||||
:query (:uri request)
|
:query (:uri request)
|
||||||
:request-method (:request-method request)
|
:request-method (:request-method request)
|
||||||
:user (dissoc (:identity request)
|
:user (dissoc (:identity request)
|
||||||
@@ -157,15 +157,15 @@
|
|||||||
(defn wrap-idle-session-timeout
|
(defn wrap-idle-session-timeout
|
||||||
[handler]
|
[handler]
|
||||||
(fn [request]
|
(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))]
|
end-time (coerce/to-date-time (::idle-timeout session))]
|
||||||
(if (and end-time (time/before? end-time (time/now)))
|
(if (and end-time (time/before? end-time (time/now)))
|
||||||
(if (get (:headers request) "hx-request")
|
(if (get (:headers request) "hx-request")
|
||||||
{:session nil
|
{:session nil
|
||||||
:status 200
|
:status 200
|
||||||
:headers {"hx-redirect" "/login"}}
|
:headers {"hx-redirect" "/login"}}
|
||||||
{:session nil
|
{:session nil
|
||||||
:status 302
|
:status 302
|
||||||
:headers {"Location" "/login"}})
|
:headers {"Location" "/login"}})
|
||||||
(when-let [response (handler request)]
|
(when-let [response (handler request)]
|
||||||
(let [session (:session response session)]
|
(let [session (:session response session)]
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
seq
|
seq
|
||||||
(pull-many (dc/db conn)
|
(pull-many (dc/db conn)
|
||||||
'[:db/id :client/name :client/code :client/locations
|
'[:db/id :client/name :client/code :client/locations
|
||||||
:client/matches :client/feature-flags
|
:client/matches :client/feature-flags
|
||||||
{:client/bank-accounts [:db/id
|
{:client/bank-accounts [:db/id
|
||||||
{:bank-account/type [:db/ident]}
|
{:bank-account/type [:db/ident]}
|
||||||
:bank-account/number
|
:bank-account/number
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
{:status 200
|
{:status 200
|
||||||
:headers {"hx-trigger" (cheshire/generate-string
|
:headers {"hx-trigger" (cheshire/generate-string
|
||||||
{"notification" (str (hiccup/html [:div (.getMessage e)]))})
|
{"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
|
:else
|
||||||
{:status 500
|
{:status 500
|
||||||
:body (pr-str e)})))))
|
:body (pr-str e)})))))
|
||||||
@@ -315,32 +315,48 @@
|
|||||||
:valid-trimmed-client-ids trimmed-clients
|
:valid-trimmed-client-ids trimmed-clients
|
||||||
:first-client-id (first valid-clients)
|
:first-client-id (first valid-clients)
|
||||||
:clients-trimmed? (not= (count trimmed-clients) (count 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]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defonce app
|
(defonce app
|
||||||
(-> route-handler
|
(-> route-handler
|
||||||
(wrap-hx-current-url-params)
|
(wrap-hx-current-url-params)
|
||||||
(wrap-guess-route)
|
(wrap-guess-route)
|
||||||
(wrap-logging)
|
(wrap-logging)
|
||||||
(wrap-trim-clients)
|
(wrap-trim-clients)
|
||||||
(wrap-hydrate-clients)
|
(wrap-hydrate-clients)
|
||||||
(wrap-store-client-in-session)
|
(wrap-store-client-in-session)
|
||||||
(wrap-gunzip-jwt)
|
(wrap-gunzip-jwt)
|
||||||
(wrap-authorization auth-backend)
|
(wrap-dev-login)
|
||||||
(wrap-authentication auth-backend
|
(wrap-authorization auth-backend)
|
||||||
(session-backend {:authfn (fn [auth]
|
(wrap-authentication auth-backend
|
||||||
(dissoc auth :exp))}))
|
(session-backend {:authfn (fn [auth]
|
||||||
|
(dissoc auth :exp))}))
|
||||||
|
|
||||||
#_(wrap-pprint-session)
|
#_(wrap-pprint-session)
|
||||||
|
|
||||||
(session-version/wrap-session-version)
|
(session-version/wrap-session-version)
|
||||||
(wrap-idle-session-timeout)
|
(wrap-idle-session-timeout)
|
||||||
(wrap-session {:store (cookie-store
|
(wrap-session {:store (cookie-store
|
||||||
{:key
|
{:key
|
||||||
(byte-array
|
(byte-array
|
||||||
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
|
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
|
||||||
|
|
||||||
#_(wrap-reload)
|
#_(wrap-reload)
|
||||||
(wrap-params)
|
(wrap-params)
|
||||||
(mp/wrap-multipart-params)
|
(mp/wrap-multipart-params)
|
||||||
(wrap-edn-params)
|
(wrap-edn-params)
|
||||||
(wrap-error)))
|
(wrap-error)))
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
(ns auto-ap.ssr.auth
|
(ns auto-ap.ssr.auth
|
||||||
(:require
|
(:require
|
||||||
[auto-ap.session-version :as session-version]
|
[auto-ap.session-version :as session-version]
|
||||||
[auto-ap.ssr.components :as com]
|
|
||||||
[auto-ap.ssr.hx :as hx]
|
[auto-ap.ssr.hx :as hx]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.ssr.ui :refer [base-page]]
|
|
||||||
[buddy.sign.jwt :as jwt]
|
[buddy.sign.jwt :as jwt]
|
||||||
[config.core :refer [env]]
|
[config.core :refer [env]]
|
||||||
|
[hiccup2.core :as hiccup]
|
||||||
[hiccup.util :as hu]))
|
[hiccup.util :as hu]))
|
||||||
|
|
||||||
(defn logout [request]
|
(defn logout [request]
|
||||||
@@ -37,69 +36,73 @@
|
|||||||
"scope" "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"}
|
"scope" "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"}
|
||||||
next (assoc "state" (hu/url-encode next))))))))
|
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]
|
(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
|
[:div.fixed.top-0.left-0.right-0.z-50.mx-auto.max-w-md.w-full.px-4.pt-6
|
||||||
:errorDetails ""
|
{:x-show "showError"
|
||||||
:showNotification false
|
"x-transition:enter" "transition duration-200 ease-out"
|
||||||
:notificationDetails ""})
|
"x-transition:enter-start" "opacity-0 -translate-y-3"
|
||||||
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"}
|
"x-transition:enter-end" "opacity-100 translate-y-0"}
|
||||||
[:div#app-contents.flex.overflow-hidden
|
[:div.relative.bg-white.rounded-xl.shadow-xl.border.border-red-200.p-4
|
||||||
[:div#main-content {:class "relative w-full h-full overflow-y-auto px-4 bg-gray-100 dark:bg-gray-900 min-h-content "}
|
[:button.absolute.right-3.top-3.p-1.text-red-400.hover:text-red-600
|
||||||
[:div#notification-holder
|
{"@click" "showError=false"}
|
||||||
[: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"}
|
svg/filled-x]
|
||||||
[:div.relative
|
[:div.flex.items-start.gap-3
|
||||||
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-blue-400
|
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
|
||||||
{"@click" "showNotification=false"}
|
[:div.flex-1.min-w-0
|
||||||
svg/filled-x]]
|
[: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
|
[:div.flex.items-center.justify-center.min-h-screen.px-4
|
||||||
{:x-show "showNotification"
|
[:div.w-full.max-w-lg
|
||||||
"x-transition:enter" "transition duration-300 transform ease-in-out"
|
[:div.flex.flex-col.items-center.mb-10
|
||||||
"x-transition:enter-start" "opacity-0 translate-y-full"
|
[:img {:src "/img/logo-big.png" :alt "Integreat" :class "h-16 brightness-0 invert"}]]
|
||||||
"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 {:class "p-4 text-lg w-full" :role "alert"}
|
[:div.bg-white.rounded-2xl.shadow-2xl.p-10
|
||||||
[:div.text-sm
|
{:style "animation: slideUp 0.4s ease-out forwards; opacity: 0;"}
|
||||||
[:pre#notification-details.text-xs {:x-html "notificationDetails"}]]]]]]
|
[:div.flex.flex-col.items-center.gap-8
|
||||||
[:div {:x-show "showError"
|
[:div.text-center
|
||||||
:x-init ""}
|
[:h1.text-2xl.font-bold.text-gray-900 "Sign in to Integreat"]
|
||||||
[: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
|
[:p.mt-2.text-base.text-gray-500 "Use your Google account to continue"]]
|
||||||
[:div.relative
|
|
||||||
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-red-600
|
|
||||||
{"@click" "showError=false"}
|
|
||||||
svg/filled-x]]
|
|
||||||
|
|
||||||
[: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
|
[:a {:href (login-url (get (:query-params request) "redirect-to"))
|
||||||
{:x-show "showError"
|
: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"}
|
||||||
"x-transition:enter" "transition duration-300"
|
svg/google
|
||||||
"x-transition:enter-start" "opacity-0"
|
"Sign in with Google"]]
|
||||||
"x-transition:enter-end" "opacity-100"}
|
|
||||||
|
|
||||||
[:div {:class "p-4 mb-4 text-lg w-full" :role "alert"}
|
[:p.mt-2.text-center.text-xs.text-gray-400
|
||||||
[:div.inline-block.w-8.h-8.mr-2 svg/alert]
|
"By signing in, you agree to our "
|
||||||
[:span.font-medium "Oh, drat! An unexpected error has occurred."]
|
[:a.underline.hover:text-gray-600 {:href "/terms"} "Terms of Service"]
|
||||||
[:div.text-sm {:x-data (hx/json {"expandError" false})}
|
" and "
|
||||||
[:p "Integreat staff have been notified and are looking into it. "]
|
[:a.underline.hover:text-gray-600 {:href "/privacy"} "Privacy Policy"]]]]]])
|
||||||
[: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 w-full max-w-md"}
|
|
||||||
[:div.p-8
|
|
||||||
[:div.flex.justify-center.mb-6
|
|
||||||
[:img {:src "/img/logo-big.png" :class "max-w-[200px]"}]]
|
|
||||||
[:div
|
|
||||||
[:a {:href (login-url (get (:query-params request) "redirect-to"))
|
|
||||||
:class "inline-flex items-center justify-center w-full px-8 py-3 text-base font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"}
|
|
||||||
"Login with Google"]]])]]]])
|
|
||||||
|
|
||||||
(defn login [request]
|
(defn login [request]
|
||||||
(base-page
|
(login-page (page-contents request)))
|
||||||
request
|
|
||||||
(page-contents request)
|
|
||||||
|
|
||||||
"Dashboard"))
|
|
||||||
@@ -80,9 +80,7 @@
|
|||||||
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
|
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
|
||||||
(let [preserved (transaction-nav-params request)]
|
(let [preserved (transaction-nav-params request)]
|
||||||
(hu/url (bidi/path-for ssr-routes/only-routes route)
|
(hu/url (bidi/path-for ssr-routes/only-routes route)
|
||||||
#_(if (or (:start-date preserved) (:end-date preserved))
|
{:date-range "month"})))
|
||||||
preserved
|
|
||||||
(merge default-params preserved)))))
|
|
||||||
|
|
||||||
(defn left-aside- [{:keys [nav page-specific]} & _]
|
(defn left-aside- [{:keys [nav page-specific]} & _]
|
||||||
[:aside {:id "left-nav",
|
[:aside {:id "left-nav",
|
||||||
|
|||||||
@@ -63,14 +63,14 @@
|
|||||||
:x-model (:x-model params)}
|
:x-model (:x-model params)}
|
||||||
(if (:disabled params)
|
(if (:disabled params)
|
||||||
[:span {:x-text "value.label"}]
|
[:span {:x-text "value.label"}]
|
||||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||||
(hh/add-class "cursor-pointer"))
|
(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}"
|
"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.down.prevent.stop" "tippy.show();"
|
||||||
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
|
||||||
:tabindex 0
|
:tabindex 0
|
||||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||||
:x-ref "input"}
|
:x-ref "input"}
|
||||||
[:input (-> params
|
[:input (-> params
|
||||||
(dissoc :class)
|
(dissoc :class)
|
||||||
(dissoc :value-fn)
|
(dissoc :value-fn)
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
|
|
||||||
(assoc
|
(assoc
|
||||||
"x-ref" "hidden"
|
"x-ref" "hidden"
|
||||||
:type "hidden"
|
:type "hidden"
|
||||||
":value" "value.value"
|
":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
|
[:div.flex.w-full.justify-items-stretch
|
||||||
[:span.flex-grow.text-left {"x-text" "value.label"}]
|
[: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"}
|
[: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"} "!")]]])
|
:x-tooltip "value.warning"} "!")]]])
|
||||||
|
|
||||||
[:template {:x-ref "dropdown"}
|
[:template {:x-ref "dropdown"}
|
||||||
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
|
||||||
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
|
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
|
||||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||||
[:input {:type "text"
|
[:input {:type "text"
|
||||||
:autofocus true
|
:autofocus true
|
||||||
:class (-> (:class params)
|
:class (-> (:class params)
|
||||||
(or "")
|
(or "")
|
||||||
(hh/add-class default-input-classes)
|
(hh/add-class default-input-classes)
|
||||||
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||||
"x-model" "search"
|
"x-model" "search"
|
||||||
"placeholder" (:placeholder params)
|
"placeholder" (:placeholder params)
|
||||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
"@change.stop" ""
|
||||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||||
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()"
|
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||||
"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()}) }})"}]
|
"@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"}
|
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
||||||
[:template {:x-for "(element, index) in elements"}
|
[: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"
|
[: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 "#"
|
:href "#"
|
||||||
":class" "active == index ? 'active' : ''"
|
":class" "active == index ? 'active' : ''"
|
||||||
|
|
||||||
"@mouseover" "active = index"
|
"@mouseover" "active = index"
|
||||||
"@mouseout" "active = -1"
|
"@mouseout" "active = -1"
|
||||||
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()"
|
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
|
||||||
"x-html" "element.label"}]]]
|
"x-html" "element.label"}]]]
|
||||||
[:template {:x-if "elements.length == 0"}
|
[: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 "}
|
[: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"]]]]]])
|
"No results found"]]]]]])
|
||||||
|
|
||||||
(defn multi-typeahead-dropdown- [params]
|
(defn multi-typeahead-dropdown- [params]
|
||||||
[:template {:x-ref "dropdown"}
|
[: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"
|
[: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();"
|
"@keydown.escape.prevent" "tippy.hide();"
|
||||||
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
|
||||||
[:div {:class (-> "relative"
|
[:div {:class (-> "relative"
|
||||||
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
|
#_(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"}
|
[: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"}
|
[: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"}]]]
|
[: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)
|
:class (-> (:class params)
|
||||||
(or "")
|
(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 "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))
|
(hh/add-class default-input-classes))
|
||||||
"x-model" "search"
|
"x-model" "search"
|
||||||
"placeholder" (:placeholder params)
|
"placeholder" (:placeholder params)
|
||||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : 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} } "
|
"@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 "}
|
[:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "}
|
||||||
[:template {:x-for "(element, index) in elements"}
|
[:template {:x-for "(element, index) in elements"}
|
||||||
[:li {":style" "index == 0 && 'border: 0 !important;'"}
|
[: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"
|
[: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 "#"
|
:href "#"
|
||||||
":class" (hx/json {"active" (hx/js-fn "active==index")
|
":class" (hx/json {"active" (hx/js-fn "active==index")
|
||||||
"implied" (hx/js-fn "all_selected && index != 0")})
|
"implied" (hx/js-fn "all_selected && index != 0")})
|
||||||
"@mouseover" "active = index"
|
"@mouseover" "active = index"
|
||||||
"@mouseout" "active = -1"
|
"@mouseout" "active = -1"
|
||||||
"@click.prevent" "toggle(element)"}
|
"@click.prevent" "toggle(element)"}
|
||||||
(checkbox- {":checked" "value.has(element.value) || all_selected"
|
(checkbox- {":checked" "value.has(element.value) || all_selected"
|
||||||
:class "group-[&.implied]:bg-green-200"})
|
:class "group-[&.implied]:bg-green-200"})
|
||||||
#_[:input {:type "checkbox"}]
|
#_[:input {:type "checkbox"}]
|
||||||
[:span {"x-html" "element.label"}]]]]
|
[:span {"x-html" "element.label"}]]]]
|
||||||
[:template {:x-if "elements.length == 0"}
|
[: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"}
|
[: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"]]]]])
|
"No results found"]]]]])
|
||||||
@@ -225,7 +226,7 @@
|
|||||||
:x-init (str "$watch('value', v => $dispatch('change')); ")
|
:x-init (str "$watch('value', v => $dispatch('change')); ")
|
||||||
:search ""
|
:search ""
|
||||||
:active -1
|
:active -1
|
||||||
:elements (cond-> [{:value "all" :label "All"}]
|
:elements (cond-> [{:value "all" :label "All"}]
|
||||||
(sequential? (:value params))
|
(sequential? (:value params))
|
||||||
(into (map (fn [v]
|
(into (map (fn [v]
|
||||||
{:value ((:value-fn params identity) v)
|
{:value ((:value-fn params identity) v)
|
||||||
@@ -237,24 +238,24 @@
|
|||||||
:x-init "value=new Set(value || []); "}
|
:x-init "value=new Set(value || []); "}
|
||||||
(if (:disabled params)
|
(if (:disabled params)
|
||||||
[:span {:x-text "value.label"}]
|
[:span {:x-text "value.label"}]
|
||||||
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
|
||||||
(hh/add-class "cursor-pointer"))
|
(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}"
|
"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.down.prevent.stop" "tippy.show();"
|
||||||
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
|
||||||
:tabindex 0
|
:tabindex 0
|
||||||
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
|
||||||
:x-ref "input"}
|
:x-ref "input"}
|
||||||
[:template {:x-for "v in Array.from(value.values())"}
|
[:template {:x-for "v in Array.from(value.values())"}
|
||||||
[:input (-> params
|
[:input (-> params
|
||||||
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||||
(assoc
|
(assoc
|
||||||
:type "hidden"
|
:type "hidden"
|
||||||
"x-bind:value" "v"))]]
|
"x-bind:value" "v"))]]
|
||||||
[:template {:x-if "value.size == 0"}
|
[:template {:x-if "value.size == 0"}
|
||||||
[:input (-> params
|
[:input (-> params
|
||||||
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||||
(assoc :type "hidden"
|
(assoc :type "hidden"
|
||||||
:value ""))]]
|
:value ""))]]
|
||||||
[:div.flex.w-full.justify-items-stretch
|
[:div.flex.w-full.justify-items-stretch
|
||||||
(multi-typeahead-selected-pill- params)
|
(multi-typeahead-selected-pill- params)
|
||||||
@@ -296,23 +297,23 @@
|
|||||||
|
|
||||||
(defn money-input- [{:keys [size] :as params}]
|
(defn money-input- [{:keys [size] :as params}]
|
||||||
[:input
|
[:input
|
||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(update :class hh/add-class "appearance-none text-right")
|
(update :class hh/add-class "appearance-none text-right")
|
||||||
(update :class #(str % (use-size size)))
|
(update :class #(str % (use-size size)))
|
||||||
(assoc :type "number"
|
(assoc :type "number"
|
||||||
:step "0.01")
|
:step "0.01")
|
||||||
(dissoc :size))])
|
(dissoc :size))])
|
||||||
|
|
||||||
(defn int-input- [{:keys [size] :as params}]
|
(defn int-input- [{:keys [size] :as params}]
|
||||||
[:input
|
[:input
|
||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(update :class hh/add-class "appearance-none text-right")
|
(update :class hh/add-class "appearance-none text-right")
|
||||||
(update :class #(str % (use-size size)))
|
(update :class #(str % (use-size size)))
|
||||||
(assoc :type "number"
|
(assoc :type "number"
|
||||||
:step "1")
|
:step "1")
|
||||||
(dissoc :size))])
|
(dissoc :size))])
|
||||||
|
|
||||||
(defn date-input- [{:keys [size] :as params}]
|
(defn date-input- [{:keys [size] :as params}]
|
||||||
[:div.shrink {:x-data (hx/json {:value (:value params)
|
[:div.shrink {:x-data (hx/json {:value (:value params)
|
||||||
@@ -321,40 +322,40 @@
|
|||||||
"x-effect" "console.log('changed to' +value)"
|
"x-effect" "console.log('changed to' +value)"
|
||||||
"@change-date.camel" "$dispatch('change')"}
|
"@change-date.camel" "$dispatch('change')"}
|
||||||
[:input
|
[:input
|
||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(assoc :x-model "value")
|
(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-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 :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
|
||||||
(assoc :type "text")
|
(assoc :type "text")
|
||||||
|
|
||||||
(assoc "autocomplete" "off")
|
(assoc "autocomplete" "off")
|
||||||
(assoc "@change" "value = $event.target.value;")
|
(assoc "@change" "value = $event.target.value;")
|
||||||
|
|
||||||
(assoc "@keydown.escape" "tippy.hide(); ")
|
(assoc "@keydown.escape" "tippy.hide(); ")
|
||||||
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
|
||||||
(update :class #(str % (use-size size) " w-full"))
|
(update :class #(str % (use-size size) " w-full"))
|
||||||
(dissoc :size))]
|
(dissoc :size))]
|
||||||
[:template {:x-ref "tooltip"}
|
[:template {:x-ref "tooltip"}
|
||||||
|
|
||||||
[:div.shrink
|
[:div.shrink
|
||||||
[:div
|
[:div
|
||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(assoc :type "text")
|
(assoc :type "text")
|
||||||
(assoc :value (:value params))
|
(assoc :value (:value params))
|
||||||
;; the data-date field has to be bound before the datepicker can be initialized
|
;; the data-date field has to be bound before the datepicker can be initialized
|
||||||
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
||||||
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
||||||
(assoc ":data-date" "value")
|
(assoc ":data-date" "value")
|
||||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||||
|
|
||||||
(update :class #(str % (use-size size) " w-full"))
|
(update :class #(str % (use-size size) " w-full"))
|
||||||
(dissoc :size :name :x-model :x-modelable))]]]])
|
(dissoc :size :name :x-model :x-modelable))]]]])
|
||||||
|
|
||||||
(defn multi-calendar-input- [{:keys [size] :as params}]
|
(defn multi-calendar-input- [{:keys [size] :as params}]
|
||||||
(let [value (str/join ", "
|
(let [value (str/join ", "
|
||||||
@@ -368,21 +369,21 @@
|
|||||||
[:template {:x-for "v in value"}
|
[:template {:x-for "v in value"}
|
||||||
[:input {:type "hidden" :name (:name params) :x-model "v"}]]
|
[:input {:type "hidden" :name (:name params) :x-model "v"}]]
|
||||||
[:div
|
[:div
|
||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(assoc :type "text")
|
(assoc :type "text")
|
||||||
(assoc :value value)
|
(assoc :value value)
|
||||||
;; the data-date field has to be bound before the datepicker can be initialized
|
;; the data-date field has to be bound before the datepicker can be initialized
|
||||||
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
|
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
|
||||||
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
|
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
|
||||||
(assoc ":data-date" "Array.prototype.join.call(value, ', ')")
|
(assoc ":data-date" "Array.prototype.join.call(value, ', ')")
|
||||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||||
|
|
||||||
(update :class #(str % (use-size size) " w-full"))
|
(update :class #(str % (use-size size) " w-full"))
|
||||||
(dissoc :size :name :x-model :x-modelable))]]))
|
(dissoc :size :name :x-model :x-modelable))]]))
|
||||||
|
|
||||||
(defn calendar-input- [{:keys [size] :as params}]
|
(defn calendar-input- [{:keys [size] :as params}]
|
||||||
(let [value (:value params)]
|
(let [value (:value params)]
|
||||||
@@ -392,21 +393,21 @@
|
|||||||
:x-model (:x-model params)}
|
:x-model (:x-model params)}
|
||||||
[:input {:type "hidden" :name (:name params) :x-model "value"}]
|
[:input {:type "hidden" :name (:name params) :x-model "value"}]
|
||||||
[:div
|
[:div
|
||||||
(-> params
|
(-> params
|
||||||
(update :class (fnil hh/add-class "") default-input-classes)
|
(update :class (fnil hh/add-class "") default-input-classes)
|
||||||
(assoc :type "text")
|
(assoc :type "text")
|
||||||
(assoc :value value)
|
(assoc :value value)
|
||||||
;; the data-date field has to be bound before the datepicker can be initialized
|
;; the data-date field has to be bound before the datepicker can be initialized
|
||||||
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
|
||||||
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
|
||||||
(assoc ":data-date" "value")
|
(assoc ":data-date" "value")
|
||||||
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
|
||||||
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
|
||||||
(assoc "x-destroy" "destroyDatepicker(dp)")
|
(assoc "x-destroy" "destroyDatepicker(dp)")
|
||||||
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
|
||||||
|
|
||||||
(update :class #(str % (use-size size) " w-full"))
|
(update :class #(str % (use-size size) " w-full"))
|
||||||
(dissoc :size :name :x-model :x-modelable))]]))
|
(dissoc :size :name :x-model :x-modelable))]]))
|
||||||
|
|
||||||
(defn field-errors- [{:keys [source key]} & rest]
|
(defn field-errors- [{:keys [source key]} & rest]
|
||||||
(let [errors (:errors (cond-> (meta source)
|
(let [errors (:errors (cond-> (meta source)
|
||||||
|
|||||||
@@ -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"}]])
|
[: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
|
(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"}]])
|
[:path {:d "M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"}]])
|
||||||
|
|
||||||
(def sun
|
(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"}]])
|
[: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
|
(def home
|
||||||
@@ -157,23 +157,23 @@
|
|||||||
[:defs]
|
[:defs]
|
||||||
[:title "navigation-next"]
|
[:title "navigation-next"]
|
||||||
[:path
|
[:path
|
||||||
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
|
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
|
||||||
:fill "none",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]
|
:stroke-linejoin "round"}]
|
||||||
[:polyline
|
[:polyline
|
||||||
{:points "19 13.498 23 9.498 19 5.498",
|
{:points "19 13.498 23 9.498 19 5.498",
|
||||||
:fill "none",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]
|
:stroke-linejoin "round"}]
|
||||||
[:path
|
[:path
|
||||||
{:d
|
{: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",
|
"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",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]])
|
:stroke-linejoin "round"}]])
|
||||||
(def play
|
(def play
|
||||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "-0.5 -0.5 24 24"}
|
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "-0.5 -0.5 24 24"}
|
||||||
@@ -187,26 +187,26 @@
|
|||||||
[:defs]
|
[:defs]
|
||||||
[:title "pencil"]
|
[:title "pencil"]
|
||||||
[:rect
|
[:rect
|
||||||
{:y "1.09",
|
{:y "1.09",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:transform "translate(11.889 -5.238) rotate(45)",
|
:transform "translate(11.889 -5.238) rotate(45)",
|
||||||
:fill "none",
|
:fill "none",
|
||||||
:stroke-linejoin "round",
|
:stroke-linejoin "round",
|
||||||
:width "6",
|
:width "6",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:x "9.268",
|
:x "9.268",
|
||||||
:height "21.284"}]
|
:height "21.284"}]
|
||||||
[:polygon
|
[:polygon
|
||||||
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
|
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
|
||||||
:fill "none",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]
|
:stroke-linejoin "round"}]
|
||||||
[:path
|
[:path
|
||||||
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
|
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
|
||||||
:fill "none",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]])
|
:stroke-linejoin "round"}]])
|
||||||
|
|
||||||
(def dollar-tag
|
(def dollar-tag
|
||||||
@@ -231,15 +231,15 @@
|
|||||||
[:path
|
[:path
|
||||||
{:d
|
{: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",
|
"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",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]
|
:stroke-linejoin "round"}]
|
||||||
[:path
|
[:path
|
||||||
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
|
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
|
||||||
:fill "none",
|
:fill "none",
|
||||||
:stroke "currentColor",
|
:stroke "currentColor",
|
||||||
:stroke-linecap "round",
|
:stroke-linecap "round",
|
||||||
:stroke-linejoin "round"}]])
|
:stroke-linejoin "round"}]])
|
||||||
|
|
||||||
(def trash
|
(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 "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 "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"}]])
|
[: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"}]])
|
||||||
|
|||||||
@@ -514,6 +514,7 @@
|
|||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||||
:hx-target "#manual-coding-section"
|
:hx-target "#manual-coding-section"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
|
:hx-sync "this:replace"
|
||||||
:hx-include "closest form"}
|
:hx-include "closest form"}
|
||||||
(fc/with-field :transaction/vendor
|
(fc/with-field :transaction/vendor
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
@@ -882,9 +883,13 @@
|
|||||||
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
|
#_(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))
|
(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)
|
(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
|
(mm/default-render-step
|
||||||
linear-wizard this
|
linear-wizard this
|
||||||
:head [:div.p-2 "Edit Transaction"]
|
:head [:div.p-2 "Edit Transaction"]
|
||||||
@@ -950,7 +955,7 @@
|
|||||||
(transaction-rules-view request)]
|
(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 {: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 {}
|
||||||
(manual-coding-section* (manual-mode-initial snapshot) request)
|
(manual-coding-section* mode request)
|
||||||
(fc/with-field :transaction/approval-status
|
(fc/with-field :transaction/approval-status
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
{:label "Status"
|
{:label "Status"
|
||||||
@@ -1429,10 +1434,13 @@
|
|||||||
(let [multi-form-state (:multi-form-state request)
|
(let [multi-form-state (:multi-form-state request)
|
||||||
snapshot (:snapshot multi-form-state)
|
snapshot (:snapshot multi-form-state)
|
||||||
step-params (:step-params multi-form-state)
|
step-params (:step-params multi-form-state)
|
||||||
mode (keyword (or (:mode step-params) "simple"))
|
mode (keyword (or (:mode step-params)
|
||||||
|
(get (:form-params request) "mode")
|
||||||
|
"simple"))
|
||||||
client-id (or (:transaction/client snapshot)
|
client-id (or (:transaction/client snapshot)
|
||||||
(-> request :entity :transaction/client :db/id))
|
(-> request :entity :transaction/client :db/id))
|
||||||
vendor-id (or (:transaction/vendor step-params)
|
vendor-id (or (:transaction/vendor step-params)
|
||||||
|
(->db-id (get step-params "transaction/vendor"))
|
||||||
(:transaction/vendor snapshot))
|
(:transaction/vendor snapshot))
|
||||||
total (Math/abs (or (-> request :entity :transaction/amount)
|
total (Math/abs (or (-> request :entity :transaction/amount)
|
||||||
(:transaction/amount snapshot)
|
(:transaction/amount snapshot)
|
||||||
@@ -1440,18 +1448,30 @@
|
|||||||
amount-mode (or (:amount-mode snapshot) "$")
|
amount-mode (or (:amount-mode snapshot) "$")
|
||||||
existing-accounts (or (seq (:transaction/accounts step-params))
|
existing-accounts (or (seq (:transaction/accounts step-params))
|
||||||
(seq (:transaction/accounts snapshot)))
|
(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))
|
(vendor-default-account vendor-id client-id))
|
||||||
render-request
|
render-request
|
||||||
(if (and (empty? existing-accounts) vendor-id client-id)
|
(-> (if (and should-populate? vendor-id client-id)
|
||||||
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||||
:transaction-account/location (or (:account/location default-account) "Shared")
|
:transaction-account/location (or (:account/location default-account) "Shared")
|
||||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||||
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
||||||
(-> request
|
(-> request
|
||||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
||||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||||
request)]
|
request)
|
||||||
|
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
||||||
(html-response
|
(html-response
|
||||||
(fc/start-form (:multi-form-state render-request) nil
|
(fc/start-form (:multi-form-state render-request) nil
|
||||||
(fc/with-field :step-params
|
(fc/with-field :step-params
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
(println (format "HTTP port: %d (.http-port)" http-port))
|
(println (format "HTTP port: %d (.http-port)" http-port))
|
||||||
(nrepl/start-server :port nrepl-port)
|
(nrepl/start-server :port nrepl-port)
|
||||||
(require 'user)
|
(require 'user)
|
||||||
(user/start-dev http-port)
|
((resolve 'user/start-dev) http-port)
|
||||||
(println "Ready.")
|
(println "Ready.")
|
||||||
@(promise)))
|
@(promise)))
|
||||||
|
|
||||||
|
|||||||
@@ -84,24 +84,24 @@
|
|||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn load-accounts [conn]
|
(defn load-accounts [conn]
|
||||||
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
(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
|
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||||
:db/id])]
|
:db/id])]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/name]]}
|
:where ['[?e :account/name]]}
|
||||||
(dc/db conn))))
|
(dc/db conn))))
|
||||||
|
|
||||||
also-merge-txes (fn [also-merge old-account-id]
|
also-merge-txes (fn [also-merge old-account-id]
|
||||||
(if old-account-id
|
(if old-account-id
|
||||||
(let [[sunset-account]
|
(let [[sunset-account]
|
||||||
(first (dc/q {:find ['?a]
|
(first (dc/q {:find ['?a]
|
||||||
:in ['$ '?ac]
|
:in ['$ '?ac]
|
||||||
:where ['[?a :account/numeric-code ?ac]]}
|
:where ['[?a :account/numeric-code ?ac]]}
|
||||||
(dc/db conn) also-merge))]
|
(dc/db conn) also-merge))]
|
||||||
(into (mapv
|
(into (mapv
|
||||||
(fn [[entity id _]]
|
(fn [[entity id _]]
|
||||||
[:db/add entity id old-account-id])
|
[:db/add entity id old-account-id])
|
||||||
(dc/q {:find ['?e '?id '?a]
|
(dc/q {:find ['?e '?id '?a]
|
||||||
:in ['$ '?ac]
|
:in ['$ '?ac]
|
||||||
:where ['[?a :account/numeric-code ?ac]
|
:where ['[?a :account/numeric-code ?ac]
|
||||||
'[?e ?at ?a]
|
'[?e ?at ?a]
|
||||||
'[?at :db/ident ?id]]}
|
'[?at :db/ident ?id]]}
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
|
|
||||||
txes (transduce
|
txes (transduce
|
||||||
(comp
|
(comp
|
||||||
(map (fn ->map [r]
|
(map (fn ->map [r]
|
||||||
(into {} (map vector header r))))
|
(into {} (map vector header r))))
|
||||||
(map (fn parse-map [r]
|
(map (fn parse-map [r]
|
||||||
{:old-account-id (:db/id (code->existing-account
|
{:old-account-id (:db/id (code->existing-account
|
||||||
@@ -160,8 +160,8 @@
|
|||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn find-bad-accounts []
|
(defn find-bad-accounts []
|
||||||
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
|
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/numeric-code ?z]
|
:where ['[?e :account/numeric-code ?z]
|
||||||
'[(<= ?z 9999)]
|
'[(<= ?z 9999)]
|
||||||
'[?x ?a ?e]]}
|
'[?x ?a ?e]]}
|
||||||
@@ -177,8 +177,8 @@
|
|||||||
[:db/retractEntity old-account-id])))
|
[:db/retractEntity old-account-id])))
|
||||||
conj
|
conj
|
||||||
[]
|
[]
|
||||||
(dc/q {:find ['?e]
|
(dc/q {:find ['?e]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/numeric-code ?z]
|
:where ['[?e :account/numeric-code ?z]
|
||||||
'[(<= ?z 9999)]]}
|
'[(<= ?z 9999)]]}
|
||||||
(dc/db conn)))))
|
(dc/db conn)))))
|
||||||
@@ -192,27 +192,27 @@
|
|||||||
(fn [acc [e z]]
|
(fn [acc [e z]]
|
||||||
(update acc z conj e))
|
(update acc z conj e))
|
||||||
{}
|
{}
|
||||||
(dc/q {:find ['?e '?z]
|
(dc/q {:find ['?e '?z]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/numeric-code ?z]]}
|
:where ['[?e :account/numeric-code ?z]]}
|
||||||
(dc/db conn)))))
|
(dc/db conn)))))
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn customize-accounts [customer filename]
|
(defn customize-accounts [customer filename]
|
||||||
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
||||||
[client-id] (first (dc/q (-> {:find ['?e]
|
[client-id] (first (dc/q (-> {:find ['?e]
|
||||||
:in ['$ '?z]
|
:in ['$ '?z]
|
||||||
:where [['?e :client/code '?z]]}
|
:where [['?e :client/code '?z]]}
|
||||||
(dc/db conn) customer)))
|
(dc/db conn) customer)))
|
||||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||||
{:account/applicability [:db/ident]}
|
{:account/applicability [:db/ident]}
|
||||||
:db/id])]
|
:db/id])]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/name]]}
|
:where ['[?e :account/name]]}
|
||||||
(dc/db conn))))
|
(dc/db conn))))
|
||||||
|
|
||||||
existing-account-overrides (dc/q {:find ['?e]
|
existing-account-overrides (dc/q {:find ['?e]
|
||||||
:in ['$ '?client-id]
|
:in ['$ '?client-id]
|
||||||
:where [['?e :account-client-override/client '?client-id]]}
|
:where [['?e :account-client-override/client '?client-id]]}
|
||||||
(dc/db conn) client-id)
|
(dc/db conn) client-id)
|
||||||
|
|
||||||
@@ -276,8 +276,8 @@
|
|||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn fix-transactions-without-locations [client-code location]
|
(defn fix-transactions-without-locations [client-code location]
|
||||||
(->>
|
(->>
|
||||||
(dc/q {:find ['(pull ?e [*])]
|
(dc/q {:find ['(pull ?e [*])]
|
||||||
:in ['$ '?client-code]
|
:in ['$ '?client-code]
|
||||||
:where ['[?e :transaction/accounts ?ta]
|
:where ['[?e :transaction/accounts ?ta]
|
||||||
'[?e :transaction/matched-rule]
|
'[?e :transaction/matched-rule]
|
||||||
'[?e :transaction/approval-status :transaction-approval-status/approved]
|
'[?e :transaction/approval-status :transaction-approval-status/approved]
|
||||||
@@ -297,8 +297,8 @@
|
|||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn entity-history [i]
|
(defn entity-history [i]
|
||||||
(vec (sort-by first (dc/q
|
(vec (sort-by first (dc/q
|
||||||
{:find ['?tx '?z '?v]
|
{:find ['?tx '?z '?v]
|
||||||
:in ['?i '$]
|
:in ['?i '$]
|
||||||
:where ['[?i ?a ?v ?tx ?ad]
|
:where ['[?i ?a ?v ?tx ?ad]
|
||||||
'[?a :db/ident ?z]
|
'[?a :db/ident ?z]
|
||||||
'[(= ?ad true)]]}
|
'[(= ?ad true)]]}
|
||||||
@@ -307,8 +307,8 @@
|
|||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn entity-history-with-revert [i]
|
(defn entity-history-with-revert [i]
|
||||||
(vec (sort-by first (dc/q
|
(vec (sort-by first (dc/q
|
||||||
{:find ['?tx '?z '?v '?ad]
|
{:find ['?tx '?z '?v '?ad]
|
||||||
:in ['?i '$]
|
:in ['?i '$]
|
||||||
:where ['[?i ?a ?v ?tx ?ad]
|
:where ['[?i ?a ?v ?tx ?ad]
|
||||||
'[?a :db/ident ?z]]}
|
'[?a :db/ident ?z]]}
|
||||||
i (dc/history (dc/db conn))))))
|
i (dc/history (dc/db conn))))))
|
||||||
@@ -354,8 +354,9 @@
|
|||||||
|
|
||||||
(defn start-dev [& [http-port]]
|
(defn start-dev [& [http-port]]
|
||||||
(set-refresh-dirs "src")
|
(set-refresh-dirs "src")
|
||||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
|
(clojure.tools.namespace.repl/disable-reload! (find-ns 'dev-mcp))
|
||||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
|
#_(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-db)
|
||||||
(start-http http-port)
|
(start-http http-port)
|
||||||
(auto-reset))
|
(auto-reset))
|
||||||
@@ -378,18 +379,18 @@
|
|||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn find-queries [words]
|
(defn find-queries [words]
|
||||||
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
|
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
|
||||||
:prefix (str "queries/"))
|
:prefix (str "queries/"))
|
||||||
concurrent 30
|
concurrent 30
|
||||||
output-chan (async/chan)]
|
output-chan (async/chan)]
|
||||||
(async/pipeline-blocking concurrent
|
(async/pipeline-blocking concurrent
|
||||||
output-chan
|
output-chan
|
||||||
(comp
|
(comp
|
||||||
(map #(do
|
(map #(do
|
||||||
[(:key %)
|
[(:key %)
|
||||||
(str (slurp (:object-content (s3/get-object
|
(str (slurp (:object-content (s3/get-object
|
||||||
:bucket-name (:data-bucket env)
|
:bucket-name (:data-bucket env)
|
||||||
:key (:key %)))))]))
|
:key (:key %)))))]))
|
||||||
|
|
||||||
(filter #(->> words
|
(filter #(->> words
|
||||||
(every? (fn [w] (str/includes? (second %) w)))))
|
(every? (fn [w] (str/includes? (second %) w)))))
|
||||||
@@ -403,9 +404,9 @@
|
|||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn upsert-invoice-amounts [tsv]
|
(defn upsert-invoice-amounts [tsv]
|
||||||
(let [data (with-open [reader (io/reader (char-array tsv))]
|
(let [data (with-open [reader (io/reader (char-array tsv))]
|
||||||
(doall (csv/read-csv reader :separator \tab)))
|
(doall (csv/read-csv reader :separator \tab)))
|
||||||
db (dc/db conn)
|
db (dc/db conn)
|
||||||
i->invoice-id (fn [i]
|
i->invoice-id (fn [i]
|
||||||
(try (Long/parseLong i)
|
(try (Long/parseLong i)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
@@ -458,7 +459,7 @@
|
|||||||
:when current-total]
|
:when current-total]
|
||||||
|
|
||||||
[(when (not (auto-ap.utils/dollars= current-total target-total))
|
[(when (not (auto-ap.utils/dollars= current-total target-total))
|
||||||
{:db/id invoice-id
|
{:db/id invoice-id
|
||||||
:invoice/total target-total})
|
:invoice/total target-total})
|
||||||
|
|
||||||
(when new-account?
|
(when new-account?
|
||||||
@@ -523,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]))]
|
(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
|
(clojure.data.csv/write-csv
|
||||||
*out*
|
*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))))
|
: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-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]]
|
{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]]
|
||||||
@@ -536,8 +537,8 @@
|
|||||||
(t/minus (t/days (rand-int 60)))
|
(t/minus (t/days (rand-int 60)))
|
||||||
(atime/unparse atime/normal-date))
|
(atime/unparse atime/normal-date))
|
||||||
id (rand-int 100000)]
|
id (rand-int 100000)]
|
||||||
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
|
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]]]
|
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
|
||||||
a)
|
a)
|
||||||
:separator \tab))))
|
:separator \tab))))
|
||||||
|
|
||||||
@@ -549,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]))]
|
(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
|
(clojure.data.csv/write-csv
|
||||||
*out*
|
*out*
|
||||||
(for [n (range n)
|
(for [n (range n)
|
||||||
:let [amount (rand-int 2000)
|
:let [amount (rand-int 2000)
|
||||||
d (-> (t/now)
|
d (-> (t/now)
|
||||||
(t/minus (t/days (rand-int 60)))
|
(t/minus (t/days (rand-int 60)))
|
||||||
@@ -565,7 +566,7 @@
|
|||||||
:in $
|
:in $
|
||||||
:where [?i :invoice/invoice-number]
|
:where [?i :invoice/invoice-number]
|
||||||
(not [?i :invoice/status :invoice-status/voided])]
|
(not [?i :invoice/status :invoice-status/voided])]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -578,7 +579,7 @@
|
|||||||
:in $
|
:in $
|
||||||
:where [?i :payment/date]
|
:where [?i :payment/date]
|
||||||
(not [?i :payment/status :payment-status/voided])]
|
(not [?i :payment/status :payment-status/voided])]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -591,7 +592,7 @@
|
|||||||
:in $
|
:in $
|
||||||
:where [?i :transaction/description-original]
|
:where [?i :transaction/description-original]
|
||||||
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
|
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -602,7 +603,7 @@
|
|||||||
(doseq [batch (->> (dc/qseq {:query '[:find ?i
|
(doseq [batch (->> (dc/qseq {:query '[:find ?i
|
||||||
:in $
|
:in $
|
||||||
:where [?i :journal-entry/date]]
|
:where [?i :journal-entry/date]]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
[auto-ap.solr]
|
[auto-ap.solr]
|
||||||
[auto-ap.ssr.components.multi-modal :as mm]
|
[auto-ap.ssr.components.multi-modal :as mm]
|
||||||
[auto-ap.ssr.form-cursor :as fc]
|
[auto-ap.ssr.form-cursor :as fc]
|
||||||
[auto-ap.ssr.transaction.edit :refer [clientize-vendor
|
[auto-ap.ssr.transaction.edit
|
||||||
edit-vendor-changed-handler
|
:refer [clientize-vendor
|
||||||
edit-wizard-toggle-mode-handler
|
edit-vendor-changed-handler
|
||||||
location-select*
|
edit-wizard-toggle-mode-handler
|
||||||
manual-coding-section*
|
location-select*
|
||||||
vendor-default-account]]
|
manual-coding-section*
|
||||||
|
vendor-default-account]]
|
||||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[hiccup.core :as hiccup]))
|
[hiccup.core :as hiccup]))
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
(testing "AC3: multi-account (2+) transaction opens in advanced mode"
|
(testing "AC3: multi-account (2+) transaction opens in advanced mode"
|
||||||
(is (= :advanced (manual-mode-initial {:db/id 123
|
(is (= :advanced (manual-mode-initial {:db/id 123
|
||||||
:transaction/accounts [{:transaction-account/account 1}
|
:transaction/accounts [{:transaction-account/account 1}
|
||||||
{:transaction-account/account 2}]})))
|
{:transaction-account/account 2}]})))
|
||||||
(is (= :advanced (manual-mode-initial {:db/id 123
|
(is (= :advanced (manual-mode-initial {:db/id 123
|
||||||
:transaction/accounts [{} {} {}]})))))
|
:transaction/accounts [{} {} {}]})))))
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
(is (re-find #"Test Account" body)
|
(is (re-find #"Test Account" body)
|
||||||
"Response should contain the vendor's default account name")))
|
"Response should contain the vendor's default account name")))
|
||||||
|
|
||||||
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account"
|
(testing "AC5: vendor selection in simple mode DOES overwrite already-set account"
|
||||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
:vendor/name "Test Vendor"}
|
:vendor/name "Test Vendor"}
|
||||||
{:db/id "account-id"
|
{:db/id "account-id"
|
||||||
@@ -126,9 +127,10 @@
|
|||||||
:transaction/client "client-id"}])
|
:transaction/client "client-id"}])
|
||||||
tx-id (tempid->id result "transaction-id")
|
tx-id (tempid->id result "transaction-id")
|
||||||
vendor-id (tempid->id result "vendor-id")
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
account-id (tempid->id result "account-id")
|
||||||
other-account-id (tempid->id result "other-account-id")
|
other-account-id (tempid->id result "other-account-id")
|
||||||
client-id (tempid->id result "client-id")
|
client-id (tempid->id result "client-id")
|
||||||
;; existing-accounts already set means vendor should NOT overwrite
|
;; existing-accounts already set — but simple mode should still overwrite
|
||||||
existing-accounts [{:db/id "row-id"
|
existing-accounts [{:db/id "row-id"
|
||||||
:transaction-account/account other-account-id
|
:transaction-account/account other-account-id
|
||||||
:transaction-account/location "DT"
|
:transaction-account/location "DT"
|
||||||
@@ -149,12 +151,12 @@
|
|||||||
;; The handler returns an html-response; verify the body is HTML
|
;; The handler returns an html-response; verify the body is HTML
|
||||||
(is (re-find #"manual-coding-section" body)
|
(is (re-find #"manual-coding-section" body)
|
||||||
"Response body should contain the manual-coding-section element")
|
"Response body should contain the manual-coding-section element")
|
||||||
;; The original account ID must still appear in the rendered HTML
|
;; The vendor's default account SHOULD appear (overwriting previous)
|
||||||
(is (re-find (re-pattern (str other-account-id)) body)
|
(is (re-find (re-pattern (str account-id)) body)
|
||||||
"Response should contain the original (pre-existing) account ID")
|
"Vendor change in simple mode should overwrite with vendor's default account")
|
||||||
;; The vendor's default account ID must NOT appear — it was not used
|
;; The previous account should NOT appear
|
||||||
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body))
|
(is (not (re-find (re-pattern (str other-account-id)) body))
|
||||||
"Response should NOT contain the vendor's default account ID when existing account is set"))))
|
"Previous account should be replaced by vendor default"))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
;;; ---------------------------------------------------------------------------
|
||||||
;;; AC6: save round-trip — manual mode saves vendor + account to DB
|
;;; AC6: save round-trip — manual mode saves vendor + account to DB
|
||||||
@@ -163,18 +165,18 @@
|
|||||||
(deftest save-manual-round-trip-test
|
(deftest save-manual-round-trip-test
|
||||||
(testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values"
|
(testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values"
|
||||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
:vendor/name "Save Vendor"}
|
:vendor/name "Save Vendor"}
|
||||||
{:db/id "account-id"
|
{:db/id "account-id"
|
||||||
:account/name "Save Account"
|
:account/name "Save Account"
|
||||||
:account/type :account-type/expense}
|
:account/type :account-type/expense}
|
||||||
{:db/id "client-id"
|
{:db/id "client-id"
|
||||||
:client/code "SAVECL"
|
:client/code "SAVECL"
|
||||||
:client/locations ["DT"]}
|
:client/locations ["DT"]}
|
||||||
{:db/id "transaction-id"
|
{:db/id "transaction-id"
|
||||||
:transaction/amount 100.0
|
:transaction/amount 100.0
|
||||||
:transaction/date #inst "2023-01-01"
|
:transaction/date #inst "2023-01-01"
|
||||||
:transaction/id (str (java.util.UUID/randomUUID))
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
:transaction/client "client-id"}])
|
:transaction/client "client-id"}])
|
||||||
tx-id (tempid->id result "transaction-id")
|
tx-id (tempid->id result "transaction-id")
|
||||||
vendor-id (tempid->id result "vendor-id")
|
vendor-id (tempid->id result "vendor-id")
|
||||||
account-id (tempid->id result "account-id")
|
account-id (tempid->id result "account-id")
|
||||||
@@ -934,3 +936,384 @@
|
|||||||
;; Should NOT show 'Switch to simple mode'
|
;; Should NOT show 'Switch to simple mode'
|
||||||
(is (not (re-find #"Switch to simple mode" html))
|
(is (not (re-find #"Switch to simple mode" html))
|
||||||
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
|
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
|
||||||
|
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
;;; Bug: vendor selection gets erased on vendor-changed HTMX response
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(deftest vendor-selection-preserved-in-htmx-response-test
|
||||||
|
(testing "BUG: vendor selection should be preserved when HTMX re-renders the edit form"
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Test Vendor"}
|
||||||
|
{:db/id "account-id"
|
||||||
|
:account/name "Existing Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "VENDORCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
account-id (tempid->id result "account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Simulate the request after middleware decoding.
|
||||||
|
;; In production, form values arrive as strings. The middleware decodes
|
||||||
|
;; step-params with keyword keys but leaves values as strings.
|
||||||
|
existing-accounts [{:db/id "row-1"
|
||||||
|
:transaction-account/account account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts existing-accounts}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
;; This is how the vendor ID arrives from the form:
|
||||||
|
;; as a string, not a long.
|
||||||
|
:transaction/vendor (str vendor-id)
|
||||||
|
:transaction/accounts existing-accounts})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
;; The handler should return a successful response with the vendor
|
||||||
|
;; preserved. Currently it crashes because the string vendor-id is
|
||||||
|
;; not converted to a long before being passed to Datomic.
|
||||||
|
response (try
|
||||||
|
(edit-vendor-changed-handler request)
|
||||||
|
(catch Exception e
|
||||||
|
{:error e}))]
|
||||||
|
(is (not (:error response))
|
||||||
|
(str "BUG: String vendor-id from form submission should be converted to long. "
|
||||||
|
"Server crashes with: " (some-> response :error ex-message)))
|
||||||
|
(when-not (:error response)
|
||||||
|
(is (= 200 (:status response))
|
||||||
|
"Response should be successful")
|
||||||
|
(is (re-find #"Test Vendor" (:body response))
|
||||||
|
"Vendor name should appear in the HTMX response")
|
||||||
|
(is (re-find (re-pattern (str vendor-id)) (:body response))
|
||||||
|
"Vendor ID should be preserved in the response HTML")))))
|
||||||
|
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
;;; Bug: vendor change does not populate account
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(deftest vendor-change-simple-mode-overwrites-test
|
||||||
|
(testing "BUG: vendor change in simple mode should overwrite existing account"
|
||||||
|
;; When a vendor is changed in simple mode, it should always populate
|
||||||
|
;; the vendor's default account, even if an account was already set.
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "existing-account-id"
|
||||||
|
:account/name "Previously Selected Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "VENDORCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
existing-account-id (tempid->id result "existing-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Simulate form state with an already-selected account (as the form submits)
|
||||||
|
existing-accounts [{:db/id "row-1"
|
||||||
|
:transaction-account/account existing-account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts existing-accounts}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts existing-accounts})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The vendor's default account SHOULD appear (overwriting the previous)
|
||||||
|
(is (re-find (re-pattern (str vendor-account-id)) body)
|
||||||
|
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
|
||||||
|
;; The previously selected account should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str existing-account-id)) body))
|
||||||
|
"Previously selected account should be replaced by vendor default")
|
||||||
|
(is (re-find #"Vendor Default Account" body)
|
||||||
|
"Vendor default account name should appear"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-advanced-mode-empty-row-test
|
||||||
|
(testing "BUG: vendor change in advanced mode should populate empty row"
|
||||||
|
;; In advanced mode with 1 empty row, changing vendor should populate it
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "ADVEMPTYCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Simulate advanced mode with 1 empty row (account=nil, as form submits)
|
||||||
|
empty-row [{:db/id "row-1"
|
||||||
|
:transaction-account/account nil
|
||||||
|
:transaction-account/location "Shared"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts empty-row}
|
||||||
|
[]
|
||||||
|
{:mode "advanced"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts empty-row})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The vendor's default account SHOULD appear in the row
|
||||||
|
(is (re-find (re-pattern (str vendor-account-id)) body)
|
||||||
|
"BUG: Vendor change in advanced mode with empty row should populate it")
|
||||||
|
(is (re-find #"Vendor Default Account" body)
|
||||||
|
"Vendor default account name should appear in the row"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-advanced-mode-filled-row-test
|
||||||
|
(testing "AC15b: vendor change in advanced mode with filled row should NOT overwrite"
|
||||||
|
;; In advanced mode with 1 row that already has an account selected,
|
||||||
|
;; changing vendor should NOT overwrite it
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "existing-account-id"
|
||||||
|
:account/name "Manually Selected Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "ADVFILLEDCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
existing-account-id (tempid->id result "existing-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Advanced mode with 1 row that already has an account
|
||||||
|
filled-row [{:db/id "row-1"
|
||||||
|
:transaction-account/account existing-account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts filled-row}
|
||||||
|
[]
|
||||||
|
{:mode "advanced"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts filled-row})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The existing account should still be there
|
||||||
|
(is (re-find (re-pattern (str existing-account-id)) body)
|
||||||
|
"Existing account should remain when vendor changes in advanced mode with filled row")
|
||||||
|
;; The vendor's default account should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str vendor-account-id)) body))
|
||||||
|
"Vendor default should NOT overwrite filled row in advanced mode"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-advanced-mode-two-rows-test
|
||||||
|
(testing "AC15c: vendor change in advanced mode with 2+ rows should NOT modify any"
|
||||||
|
;; In advanced mode with 2 or more rows, vendor change should not touch any row
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "account-1"
|
||||||
|
:account/name "Account One"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "account-2"
|
||||||
|
:account/name "Account Two"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "ADVTWOROWCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
account-1 (tempid->id result "account-1")
|
||||||
|
account-2 (tempid->id result "account-2")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Advanced mode with 2 rows
|
||||||
|
two-rows [{:db/id "row-1"
|
||||||
|
:transaction-account/account account-1
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 50.0}
|
||||||
|
{:db/id "row-2"
|
||||||
|
:transaction-account/account account-2
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 50.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts two-rows}
|
||||||
|
[]
|
||||||
|
{:mode "advanced"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts two-rows})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; Both existing accounts should remain
|
||||||
|
(is (re-find (re-pattern (str account-1)) body)
|
||||||
|
"First row account should remain")
|
||||||
|
(is (re-find (re-pattern (str account-2)) body)
|
||||||
|
"Second row account should remain")
|
||||||
|
;; Vendor default should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str vendor-account-id)) body))
|
||||||
|
"Vendor default should NOT modify rows when 2+ exist"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-client-specific-override-test
|
||||||
|
(testing "BUG: vendor change should use client-specific account override if present"
|
||||||
|
;; When a vendor has a client-specific account override, changing vendor
|
||||||
|
;; should populate the client-specific account, not the global default.
|
||||||
|
(let [result @(dc/transact conn [{:db/id "global-account-id"
|
||||||
|
:account/name "Global Default"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-specific-account-id"
|
||||||
|
:account/name "Client Specific Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "CLIOVERRIDE"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/name "Clientized Vendor"
|
||||||
|
:vendor/default-account "global-account-id"
|
||||||
|
:vendor/account-overrides [{:vendor-account-override/client "client-id"
|
||||||
|
:vendor-account-override/account "client-specific-account-id"}]}])
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
global-account-id (tempid->id result "global-account-id")
|
||||||
|
client-specific-account-id (tempid->id result "client-specific-account-id")
|
||||||
|
;; Simple mode with empty account row
|
||||||
|
empty-row [{:db/id "row-1"
|
||||||
|
:transaction-account/account nil
|
||||||
|
:transaction-account/location "Shared"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id 999999
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts empty-row}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts empty-row})
|
||||||
|
:entity {:db/id 999999
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The client-specific account should appear, not the global default
|
||||||
|
(is (re-find (re-pattern (str client-specific-account-id)) body)
|
||||||
|
"BUG: Vendor change should populate client-specific account override")
|
||||||
|
(is (re-find #"Client Specific Account" body)
|
||||||
|
"Client-specific account name should appear")
|
||||||
|
;; The global default should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str global-account-id)) body))
|
||||||
|
"Global vendor default should NOT appear when client override exists"))))
|
||||||
|
|
||||||
|
;;; Update AC5: simple mode SHOULD overwrite existing accounts
|
||||||
|
(deftest vendor-change-simple-mode-overwrites-ac5-test
|
||||||
|
(testing "AC5 UPDATED: vendor selection in simple mode DOES overwrite already-set account"
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Test Vendor"}
|
||||||
|
{:db/id "account-id"
|
||||||
|
:account/name "Test Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "account-id"}
|
||||||
|
{:db/id "other-account-id"
|
||||||
|
:account/name "Other Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "TESTCL2"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
account-id (tempid->id result "account-id")
|
||||||
|
other-account-id (tempid->id result "other-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; existing-accounts already set — but simple mode should still overwrite
|
||||||
|
existing-accounts [{:db/id "row-id"
|
||||||
|
:transaction-account/account other-account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts existing-accounts}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts existing-accounts})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The handler returns an html-response; verify the body is HTML
|
||||||
|
(is (re-find #"manual-coding-section" body)
|
||||||
|
"Response body should contain the manual-coding-section element")
|
||||||
|
;; The vendor's default account SHOULD appear (overwriting previous)
|
||||||
|
(is (re-find (re-pattern (str account-id)) body)
|
||||||
|
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
|
||||||
|
;; The previous account should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str other-account-id)) body))
|
||||||
|
"Previous account should be replaced by vendor default"))))
|
||||||
|
|||||||
Reference in New Issue
Block a user