Compare commits
24 Commits
216b5b437a
...
0.34
| Author | SHA1 | Date | |
|---|---|---|---|
| aaa876e593 | |||
| f25552eef2 | |||
| 4276d7b915 | |||
| f6ba85b18f | |||
| 919845518c | |||
| fe935235e8 | |||
| e22ab8cc49 | |||
| e53e238ca6 | |||
| 309c26f209 | |||
| 01573faced | |||
| 689f1c01fd | |||
| cb8a09b571 | |||
| 7f87e63b7c | |||
| 95641036cc | |||
| 505d635c66 | |||
| 9f0e4b0ac3 | |||
| 2cbd98df1a | |||
| e618e906ef | |||
| 96e574e6c7 | |||
| 6a8aa37198 | |||
| aa0c17f521 | |||
| 464eeeb2b1 | |||
| daac5d7392 | |||
| c85748b3aa |
74
CHANGELOG.md
74
CHANGELOG.md
@@ -1,5 +1,79 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
|
||||||
|
|
||||||
|
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
||||||
|
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
|
||||||
|
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
|
||||||
|
|
||||||
|
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
|
||||||
|
|
||||||
|
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
|
||||||
|
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
|
||||||
|
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
|
||||||
|
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
|
||||||
|
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
|
||||||
|
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
|
||||||
|
|
||||||
|
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
||||||
|
|
||||||
|
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
||||||
|
- Implemented `GET /sync-bank`: runs Fio sync → infer payments → cache flush, captures output into `sync.tmpl` page with success/error banner.
|
||||||
|
- Implemented `GET /flush-cache` + `POST /flush-cache`: form + action that deletes cache files and shows deleted count.
|
||||||
|
- Added `GET /version` as a JSON alias of `GET /api/version` (Python parity).
|
||||||
|
- Added `FlushCache() (int, error)` to `membership.Sources` interface; implemented on `realSources` via `cache.FileCache.Flush()`.
|
||||||
|
- Introduced `web.ActionHandlers{BankSync}` — closure-based dep injection for sync, constructed in `serverCmd` with fio + sheets clients.
|
||||||
|
- New dependency: `github.com/skip2/go-qrcode`.
|
||||||
|
- Key files: `go/internal/web/qr.go`, `go/internal/web/html_handler.go`, `go/internal/web/server.go`, `go/internal/services/membership/loader.go`, `go/internal/web/templates/sync.tmpl`, `go/internal/web/templates/flush_cache.tmpl`.
|
||||||
|
|
||||||
|
## 2026-05-08 13:19 CEST — feat(go): M6.5 — member-detail modal JS module
|
||||||
|
|
||||||
|
- Added `static/js/member-detail.js`: fetches `/api/adults` or `/api/juniors` once on page load, caches the response, renders a per-member detail modal on `[i]` row click.
|
||||||
|
- Modal sections: status-per-month table, fee exceptions, other transactions, matched payment history, toggleable raw-payments debug view.
|
||||||
|
- Keyboard nav: `Esc` closes, `↑`/`↓` walk visible (name-filtered) rows; click outside modal content closes it.
|
||||||
|
- Added `[i]` info icon and `#memberModal` markup to `adults.tmpl` and `juniors.tmpl`; all CSS was already in place from M6.1.
|
||||||
|
- Added `TestModalMarkup` assertions to `html_handler_test.go`.
|
||||||
|
|
||||||
|
## 2026-05-08 12:48 CEST — feat(go): M6.4 — Go-native /payments page
|
||||||
|
|
||||||
|
- Extracted `AssemblePayments(ctx)` from the inlined JSON handler body, following the M6.2/M6.3 `AssembleAdults`/`AssembleJuniors` pattern.
|
||||||
|
- Added `PaymentsPageData` to `render.go`; wired `HTMLHandler.ServePayments` to call `AssemblePayments` and render the new template.
|
||||||
|
- Replaced the "Coming in M6.4" placeholder in `payments.tmpl` with the full grouped-by-person ledger: alphabetical `<h2>` member blocks, each with a `txn-table` (Date / Amount / Purpose / Bank Message), newest-first rows, `"Unmatched / Unknown"` bucket.
|
||||||
|
- Appended ledger CSS classes to `app.css` (`.ledger-container`, `.member-block`, `.txn-table`, `.txn-{date,amount,purpose,message}`, `tr:hover`).
|
||||||
|
- Added `TestPaymentsPage` markup test. No filters, no JS — matches the Python page.
|
||||||
|
|
||||||
|
## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page
|
||||||
|
|
||||||
|
- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`).
|
||||||
|
- Added `JuniorsPageData` to `render.go`; wired `HTMLHandler.ServeJuniors` to call `AssembleJuniors` and render the new template.
|
||||||
|
- Replaced the 4-line "Coming in M6.3" placeholder in `juniors.tmpl` with the full template: member table, name/month-range filters, totals row, Credits + Debts sections, Pay / Pay All links to `/qr`. No Unmatched section (parity with Python).
|
||||||
|
- J/A attendance breakdown and `"?"` sentinel rendered via `MonthCell.Text` produced by `buildJuniorMemberRow` — no extra template logic.
|
||||||
|
- `make parity` reports 3/3 routes OK; all Go tests pass.
|
||||||
|
|
||||||
|
## 2026-05-08 11:11 CEST — fix: period selector showed only Dec 2025+ on adults
|
||||||
|
|
||||||
|
- Restored the `2025-09 → 2025-10` adult merge in `scripts/attendance.py` and `go/internal/services/membership/sources.go` (commented-out by `1257f0d`); the `2025-12 → 2026-01` mapping stays disabled per product decision (Dec and Jan are billed separately for adults).
|
||||||
|
- Dropped the `defaultFrom = maxMonthIdx − 4` JS auto-default in `templates/adults.html` and `templates/juniors.html` (introduced by `7774301`); the From-selector now starts at the oldest available month so all non-future periods render on first load. Future-month removal is preserved.
|
||||||
|
- `go/internal/services/membership/sources_test.go`: `TestLoadAdults` / `TestLoadAdults_Fee` now assert that Sep dates land in the merged `2025-10` bucket.
|
||||||
|
- **Independent of these code changes**: the live adults attendance Google Sheet header had been pruned to start at `02.12.2025` (Sep/Oct/Nov 2025 columns deleted); restoring those columns from Sheets version history is required to actually see those periods on the dashboard.
|
||||||
|
|
||||||
|
## 2026-05-08 10:15 CEST — fix(go): adults template — use lifted CSS classes for visual parity
|
||||||
|
|
||||||
|
- Use existing `.balance-pos` / `.balance-neg` (drop invented `balance-cell` / `balance-negative`); Pay-All button now lives inside the balance cell with `position: relative` (matches Python; no separate trailing column).
|
||||||
|
- Credits / Debts / Unmatched sections rewritten from `<ul>` / `<table>` to `<div class="list-container"><div class="list-item">…` / `<div class="unmatched-row">…` so the lifted CSS actually applies.
|
||||||
|
- Section headings get the descriptive Python text: "Credits (Advance Payments / Surplus)", "Debts (Missing Payments)".
|
||||||
|
- Source links moved from page bottom to a `<div class="description">` block under the h1, matching Python's "Source: Attendance Sheet | Payments Ledger".
|
||||||
|
- Total row uses `cell-{{Status}}` plus the small "received / expected" caption span and Python's inline-styled bold/dark background.
|
||||||
|
- Drop the redundant `class="cell"` wrapper; debts amount turns red via inline style; balance value drops the trailing "CZK".
|
||||||
|
|
||||||
|
## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons)
|
||||||
|
|
||||||
|
- `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler.
|
||||||
|
- `go/internal/web/render.go`: added `AdultsPageData` view model (`PageData` + `api.AdultsResponse` + `Error`); `tmplFuncs` with `qrHref` / `qrHrefAll` (URL-encode QR Platba params, convert YYYY-MM → MM/YYYY).
|
||||||
|
- `go/internal/web/html_handler.go`: `HTMLHandler` gains `*api.Handler`; `ServeAdults` loads real reconcile data and renders the full adults page.
|
||||||
|
- `go/internal/web/templates/adults.tmpl`: full table (per-member rows, per-cell status classes, `data-month-idx`, Pay button hrefs to `/qr`), totals row, credits/debts/unmatched sections, filter controls, sheet links.
|
||||||
|
- `go/internal/web/static/js/filters.js`: name filter (NFD-normalize) + month-range hide/show by `data-month-idx`; future months hidden by default.
|
||||||
|
|
||||||
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
|
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
|
||||||
|
|
||||||
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
|
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -35,6 +35,7 @@ help:
|
|||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||||
|
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
|
||||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||||
@@ -125,6 +126,9 @@ sync-2025: $(PYTHON)
|
|||||||
sync-2026: $(PYTHON)
|
sync-2026: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||||
|
|
||||||
|
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
|
||||||
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
|
||||||
|
|
||||||
infer: $(PYTHON)
|
infer: $(PYTHON)
|
||||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
|
|||||||
|
|
||||||
**Current milestone:** M6 — Go-native HTML frontend
|
**Current milestone:** M6 — Go-native HTML frontend
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-08 (M6.1)
|
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -110,12 +110,13 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
|
|||||||
|
|
||||||
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||||
|
|
||||||
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
||||||
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
|
||||||
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||||
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
|
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
|
||||||
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
|
||||||
|
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
|
||||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||||
|
|
||||||
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
||||||
|
|||||||
131
docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Normal file
131
docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Plan: Go rewrite — M6.2 Adults page
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M6.1 landed the template skeleton, embed.FS, and HTML routes ([progress §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113), commit `78e5059`). Every page renders chrome + a "Coming in M6.X" placeholder body. **M6.2 fills in `/adults`** — the most data-rich page and the template that drives the rest of the M6 work (juniors mostly mirrors it).
|
||||||
|
|
||||||
|
Acceptance criteria, verbatim from [progress §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114):
|
||||||
|
|
||||||
|
> `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
||||||
|
|
||||||
|
The Go side has done excellent prep: `buildAdultsResponse` in [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) already produces the full view model (currently consumed only by `/api/adults`), the wire types in [go/internal/web/api/types.go](../../go/internal/web/api/types.go) match the Python view-model 1:1, and the CSS is already lifted into [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css). M6.2 is therefore mostly **template authoring + handler wiring + a small filters script**, not a fresh port.
|
||||||
|
|
||||||
|
## Current Go web state (what we're building on)
|
||||||
|
|
||||||
|
- [go/internal/web/server.go:49](../../go/internal/web/server.go#L49) — `mux.HandleFunc("GET /adults", hh.ServeAdults)` already wired.
|
||||||
|
- [go/internal/web/html_handler.go:16-18](../../go/internal/web/html_handler.go#L16-L18) — placeholder handler renders a `PageData{Active, Build}` shell only. Needs to gain access to the data layer.
|
||||||
|
- [go/internal/web/render.go:11](../../go/internal/web/render.go#L11) — `PageData` struct is currently `{Active, Build}` only. Needs to extend or be wrapped by a typed adults view model.
|
||||||
|
- [go/internal/web/api/handler.go:36-45](../../go/internal/web/api/handler.go#L36-L45) — `ServeAdults` already does `loadAll` → `Reconcile` → `buildAdultsResponse`. We will extract that body into an exported method so the HTML handler can reuse it without duplication.
|
||||||
|
- [go/internal/web/api/build_adults.go:17](../../go/internal/web/api/build_adults.go#L17) — `buildAdultsResponse(...) AdultsResponse` returns everything the template needs (`Months`, `Results`, `Totals`, `Credits`, `Debts`, `Unmatched`, `BankAccount`, `CurrentMonth`, …). We pass this struct straight into the template.
|
||||||
|
- [go/internal/web/templates/adults.tmpl](../../go/internal/web/templates/adults.tmpl) — currently a 5-line placeholder. Replace contents.
|
||||||
|
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — all selectors needed (`.cell-ok`, `.cell-unpaid`, `.cell-unpaid-current`, `.cell-overridden`, `.unmatched-row`, `.filter-container`, `.pay-btn`, `.member-row`, …) already present from M6.1.
|
||||||
|
|
||||||
|
Reference for parity: [templates/adults.html](../../templates/adults.html) (Python source). Sections to mirror in markup terms:
|
||||||
|
- Reconcile table — [adults.html:534-585](../../templates/adults.html#L534-L585)
|
||||||
|
- Totals row — [adults.html:571-582](../../templates/adults.html#L571-L582)
|
||||||
|
- Credits / Debts — [adults.html:587-609](../../templates/adults.html#L587-L609)
|
||||||
|
- Unmatched — [adults.html:611-629](../../templates/adults.html#L611-L629)
|
||||||
|
- Filter controls — [adults.html:515-532](../../templates/adults.html#L515-L532)
|
||||||
|
- Filter JS (name + month range) — [adults.html:1019-1051](../../templates/adults.html#L1019-L1051)
|
||||||
|
|
||||||
|
The member-detail modal, Pay-preview modal, and JSON-hydrated `memberData`/`rawPaymentsByPerson` globals are explicitly **out of scope for M6.2** — they belong to M6.5 (modal JS).
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. **Share data assembly between HTML and JSON** — extract `ServeAdults`'s body into `(*api.Handler).AssembleAdults(ctx) (AdultsResponse, error)` and have `ServeAdults` call it. The HTML handler then calls the same method, keeping `/adults` and `/api/adults` byte-identical in semantics (same loaded data, same reconcile, same view model).
|
||||||
|
2. **Wire HTMLHandler to data** — extend `HTMLHandler` with an `apiHandler *api.Handler` field and pass it from `Run()`. `ServeAdults` becomes: `AssembleAdults` → on error render an error body (or 500) → render `adults.tmpl` with a typed view model wrapping `PageData` + `api.AdultsResponse`.
|
||||||
|
3. **Per-page typed view model** — add `AdultsPageData{ PageData; Data api.AdultsResponse }` in [render.go](../../go/internal/web/render.go) (or a new `view.go`). Template references `.Active`, `.Build` (chrome) and `.Data.Results`, `.Data.Totals`, `.Data.Months`, etc. Keeps the chrome contract for nav/footer untouched.
|
||||||
|
4. **Author `adults.tmpl`** — port markup from [templates/adults.html:489-629](../../templates/adults.html#L489-L629). Notable mechanics:
|
||||||
|
- Filter controls block with `<input id="nameFilter">`, `<select id="fromMonth">`, `<select id="toMonth">`, `Apply` / `All` buttons. Month dropdown options rendered server-side from `.Data.Months` (no client-side hydration needed for filters).
|
||||||
|
- Reconcile table iterates `.Data.Results`; each row's months iterate `row.Months`. Cell `<td>` gets class `cell-{{.Status}}`, plus `cell-unpaid-current` when `.RawMonth >= .Data.CurrentMonth`, plus `cell-overridden` when `.Overridden`. Cells carry `data-month-idx="{{$i}}"` so the filter script can hide columns.
|
||||||
|
- Per-cell Pay button visible on hover when `(unpaid|partial)` and `RawMonth < CurrentMonth` — `<a class="pay-btn" href="/qr?...">Pay</a>` with the QR query string built server-side via a template helper (see Key design notes).
|
||||||
|
- Per-row "Pay All" button when `.PayableAmount > 0`, same href construction using `.UnpaidPeriods` for display and `.RawUnpaidPeriods` for the QR message.
|
||||||
|
- Totals `<tr>` iterates `.Data.Totals`, classes `total-cell-{{.Status}}`.
|
||||||
|
- Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
|
||||||
|
5. **Tiny filter script** — new [go/internal/web/static/js/filters.js](../../go/internal/web/static/js/filters.js):
|
||||||
|
- `nameFilter` `input` event: NFD-normalize + lowercase + substring match against `.member-row [data-name]` (or row's first cell text); toggle `display:none` on non-matches.
|
||||||
|
- `fromMonth` / `toMonth` `change` event + `Apply` button: read selected `data-month-idx` range, toggle `month-hidden` class on `[data-month-idx]` `<th>`/`<td>` outside the range.
|
||||||
|
- `All` button: clear filters, restore all rows/cells.
|
||||||
|
- Match Python's behaviour byte-for-byte from [adults.html:864-1051](../../templates/adults.html#L864-L1051) but trimmed of modal/`memberData` calls.
|
||||||
|
- Loaded via `<script src="/static/js/filters.js" defer></script>` from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include).
|
||||||
|
6. **`<a href="/qr?…">` Pay buttons now, modal in M6.5** — the M6.6 milestone adds `/qr`. Until then, hrefs return 404. M6.5 will layer modal preview behaviour on top by wrapping clicks; the markup stays the same. This is the simplest staged rollout.
|
||||||
|
7. **Template helper for QR href** — add a `funcMap` to `Renderer` with `qrHref(account, amount, name, month string) string` (and a periods-list variant) that builds `/qr?account=…&amount=…&message=…` with proper URL encoding. Implementation: `net/url.Values{}.Encode()`. This keeps URL-construction logic out of the template syntax (`html/template` URL escaping isn't enough — query-param building deserves a Go helper).
|
||||||
|
8. **Active-link key** — `Active: "adults"` already set; nav highlighting works as-is (verified in M6.1 smoke test).
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
```
|
||||||
|
go/internal/web/
|
||||||
|
├── api/
|
||||||
|
│ └── handler.go MODIFY — extract ServeAdults body to AssembleAdults(ctx)
|
||||||
|
├── html_handler.go MODIFY — hold *api.Handler; ServeAdults loads + renders
|
||||||
|
├── render.go MODIFY — add AdultsPageData type; add funcMap with qrHref()
|
||||||
|
├── server.go MODIFY — pass *api.Handler into NewHTMLHandler
|
||||||
|
├── html_handler_test.go MODIFY — add adults markup-level assertions w/ stub Sources
|
||||||
|
├── templates/
|
||||||
|
│ └── adults.tmpl MODIFY — replace placeholder w/ full table + filters + sections
|
||||||
|
└── static/
|
||||||
|
└── js/
|
||||||
|
└── filters.js NEW — name + month-range client-side filtering
|
||||||
|
```
|
||||||
|
|
||||||
|
No new packages; no domain or wire-type changes.
|
||||||
|
|
||||||
|
## Key design notes
|
||||||
|
|
||||||
|
- **Reuse `buildAdultsResponse` verbatim** — no parallel "view model" needed. `AdultsResponse` already has every field the template wants. `/adults` and `/api/adults` consume the same struct.
|
||||||
|
- **Extracting `AssembleAdults`** preserves the parity contract: anything that changes the JSON also changes the HTML, by construction. (It also sets up the same pattern for M6.3 juniors and M6.4 payments.)
|
||||||
|
- **Filter UX is DOM-driven**, no JSON hydration. `member_data` / `raw_payments` JSON payloads stay deferred to M6.5 (modal needs them; filters do not).
|
||||||
|
- **`current_month` boundary is server-side** — `AdultsResponse.CurrentMonth` (set from `time.Now().Format("2006-01")` in `loadAll`) is what the template compares `RawMonth` against for `cell-unpaid-current` styling and Pay-button visibility. Same value Python passes through `vm["current_month"]`.
|
||||||
|
- **`html/template` autoescaping is sufficient** for member names, sender, message, etc. — but Pay-button URLs need explicit `url.Values` encoding (Czech names have diacritics and spaces). Hence the `qrHref` funcMap helper.
|
||||||
|
- **Error rendering**: if `AssembleAdults` fails, render the base shell with an error banner inside `content` rather than `http.Error`. Keeps nav visible so the user can navigate away. Match Python's `"No data."` fallback at [app.py:233](../../app.py#L233) for empty results.
|
||||||
|
- **No JS framework, no bundler** — filter script is plain ES2020, ~80 lines, inline-readable. Matches M6.1's "no JS in M6.1" follow-through (we add JS in M6.2, but kept minimal).
|
||||||
|
- **NFD-normalize** in JS via `s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()` to match Python's `unicodedata.normalize('NFD', ...)`. Python ref: [adults.html:868-873](../../templates/adults.html#L868-L873).
|
||||||
|
- **Filter persistence is out of scope** — Python's filters are session-only (no localStorage). Same here.
|
||||||
|
|
||||||
|
## Critical files to read
|
||||||
|
|
||||||
|
- [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) — full view model already built; the template just iterates over `AdultsResponse`.
|
||||||
|
- [go/internal/web/api/types.go:91-122](../../go/internal/web/api/types.go#L91-L122) — `MonthCell`, `TotalCell`, `MemberRow`, `Credit` shapes.
|
||||||
|
- [go/internal/web/api/handler.go:36-96](../../go/internal/web/api/handler.go#L36-L96) — pattern for `loadAll` + `Reconcile` + `buildAdultsResponse`; the bit to refactor into `AssembleAdults`.
|
||||||
|
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — class names available; no edits needed.
|
||||||
|
- [templates/adults.html:489-629](../../templates/adults.html#L489-L629) — markup reference for filter controls, table, totals, credits/debts, unmatched.
|
||||||
|
- [templates/adults.html:864-1051](../../templates/adults.html#L864-L1051) — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All).
|
||||||
|
- [tests/test_app.py:50-75](../../tests/test_app.py#L50-L75) — `test_adults_route` in Python, mirror its assertions in Go: status 200, body contains member name + `750/750 CZK (4)` + `Adults Dashboard`, does not contain `OK`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end smoke after `make go-build`:
|
||||||
|
|
||||||
|
1. `make web-go &` — server boots, no template-parse errors.
|
||||||
|
2. `curl -i localhost:8080/adults` → `200`, `Content-Type: text/html`.
|
||||||
|
3. Browser at `http://localhost:8080/adults` against real data:
|
||||||
|
- Table renders with one row per adult member, one column per month, totals row at bottom.
|
||||||
|
- Cell colors match `/api/adults` JSON — pick a `Credits` member from JSON, confirm green-ish status; pick a `Debts` member, confirm red-ish.
|
||||||
|
- Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
|
||||||
|
- Per-cell `Pay` buttons appear on hover for past-month unpaid/partial cells; href contains `/qr?account=...&amount=...&message=`.
|
||||||
|
- Per-row "Pay All" button shows for members with `payable_amount > 0`.
|
||||||
|
4. Filters:
|
||||||
|
- Type a partial member name in `#nameFilter` → only matching rows visible. Test with diacritics (e.g. `nemec` matches `Němec`).
|
||||||
|
- Pick `fromMonth=2026-02`, `toMonth=2026-04`, click `Apply` → only those columns visible (table + totals row).
|
||||||
|
- Click `All` → everything restored.
|
||||||
|
5. `curl -s localhost:8080/adults | grep -F 'Adults Dashboard'` matches; `grep -F 'Coming in M6.2'` does **not** match.
|
||||||
|
6. `make go-test` — `html_handler_test.go` adults assertions pass with stub Sources fixture (replicating `test_adults_route` from Python).
|
||||||
|
7. `make go-lint` clean.
|
||||||
|
8. `make parity` still green — `/api/adults` JSON unchanged because `AssembleAdults` extraction is a pure refactor.
|
||||||
|
9. CHANGELOG entry per CLAUDE.md (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||||
|
10. Tick M6.2 in [progress tracker §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114) with the merge commit SHA.
|
||||||
|
|
||||||
|
## Branch & MR
|
||||||
|
|
||||||
|
Branch `feat/go-m6-2-adults-page` per CLAUDE.md branch-per-feature workflow. Commit, push with `-u`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr create \
|
||||||
|
--title "feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)" \
|
||||||
|
--description "..." \
|
||||||
|
--base main \
|
||||||
|
--head feat/go-m6-2-adults-page
|
||||||
|
```
|
||||||
|
|
||||||
|
Print the PR URL for the user. User merges in Gitea browser.
|
||||||
142
docs/plans/2026-05-08-1052-period-selector-missing-old-months.md
Normal file
142
docs/plans/2026-05-08-1052-period-selector-missing-old-months.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Period selector missing older months — diagnosis
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
User reports the "From / To" period selector on the **adults** dashboard now
|
||||||
|
shows **Dec 2025** as the oldest available period. The older production
|
||||||
|
deployment shows Sep+Oct 2025, Nov 2025, Dec 2025+Jan 2026 (merged labels) —
|
||||||
|
i.e. data going back to September 2025. The user wants to know what went
|
||||||
|
sideways. Confirmed: the dropdown options on both Python and Go genuinely
|
||||||
|
start at Dec 2025, not just the default selection — Sep/Oct/Nov 2025 are not
|
||||||
|
in the list at all.
|
||||||
|
|
||||||
|
## Root cause — the live adults sheet header is missing those columns
|
||||||
|
|
||||||
|
The fresh cache files at `tmp/go/attendance_regular_cache.json` (raw rows from
|
||||||
|
Google Sheets, modifiedTime `2026-05-06T22:30:02Z`, cached `2026-05-08T00:26`)
|
||||||
|
contain the actual header row for the adults tab (gid=0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
['FUJ tréninky úterý 20:30-22:00', '', '', '02.12.2025', '09.12.2025',
|
||||||
|
'16.12.2025', '06.01.2026', '13.01.2026', '20.01.2026', '27.01.2026',
|
||||||
|
'03.02.2026', '10.02.2026', '17.02.2026', '24.02.2026', '03.03.2026',
|
||||||
|
'10.03.2026', '17.03.2026', '23.03.2026', '31.03.2026', '13.04.2026',
|
||||||
|
'20.04.2026', '27.04.2026', '04.05.2026', '', '', '']
|
||||||
|
```
|
||||||
|
|
||||||
|
**The first date column in the live adults sheet is `02.12.2025` (Dec 2 2025).**
|
||||||
|
There are no September, October, or November 2025 columns in the header at
|
||||||
|
all. Both backends parse this faithfully (no slicing, no cutoff anywhere) and
|
||||||
|
correctly produce `sortedMonths = ["2025-12", "2026-01", …, "2026-05"]`.
|
||||||
|
|
||||||
|
The juniors sheet (different tab, `JUNIOR_SHEET_GID`) is **fine** — its header
|
||||||
|
still contains `['', 'tier', '', '15.09.2025', '13.10.2025', '20.10.2025',
|
||||||
|
'03.11.2025', '24.11.2025', '10.11.2025', '17.11.2025', '01.12.2025', …]`. So
|
||||||
|
the juniors page still shows Sep+Oct / Nov / Dec+Jan correctly.
|
||||||
|
|
||||||
|
So this is a **data issue in the adults attendance Google Sheet**: at some
|
||||||
|
point between when production's cache was last warmed (showing Sep–Nov) and
|
||||||
|
2026-05-06, somebody (or some action) removed the columns for September,
|
||||||
|
October, and November 2025 from the adults tab header.
|
||||||
|
|
||||||
|
The code is doing exactly what it should. There is no parser regression.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
### 1. Restore the missing date columns in the adults attendance sheet
|
||||||
|
|
||||||
|
The fix lives in Google Sheets, not in the codebase. Options, in order of
|
||||||
|
preference:
|
||||||
|
|
||||||
|
- **(a) Use Sheets version history.** File → Version history → See version
|
||||||
|
history; find a version from before the columns were dropped (anything
|
||||||
|
before about Mar 2026 should still have them). Copy the Sep/Oct/Nov 2025
|
||||||
|
date column headers and the `TRUE/FALSE` cells underneath them back into
|
||||||
|
the current sheet. Only restore the 11 missing date columns; do not
|
||||||
|
full-revert (you'd lose every change since then).
|
||||||
|
- **(b) Pull from the production server's cache.** The production deployment
|
||||||
|
evidently still has the older cache, since its dashboard renders those
|
||||||
|
months. SSH there, copy `tmp/attendance_regular_cache.json`, and you can
|
||||||
|
reconstruct the per-member Sep/Oct/Nov attendance counts from the
|
||||||
|
`data[*][2]` map (keys `"2025-09"`, `"2025-10"`, `"2025-11"`). Re-enter
|
||||||
|
those into the sheet manually as date columns + `TRUE` cells — tedious but
|
||||||
|
deterministic.
|
||||||
|
- **(c) Accept the loss.** If the older columns aren't recoverable, the
|
||||||
|
dashboard correctly reflects what the sheet contains; nothing more to do.
|
||||||
|
|
||||||
|
Which to pick depends on whether those months still need to be billed /
|
||||||
|
reconciled.
|
||||||
|
|
||||||
|
### 2. Restore `ADULT_MERGED_MONTHS` (user confirmed this was unintentional)
|
||||||
|
|
||||||
|
Independent of the sheet issue: commit `1257f0d` (Mar 9 2026) commented out
|
||||||
|
the adult merge mappings. Once the Sep/Oct/Nov columns are back in the sheet,
|
||||||
|
the dashboard would still show them as separate periods instead of the
|
||||||
|
production-style "Sep+Oct 2025" and "Dec 2025+Jan 2026" merged labels.
|
||||||
|
|
||||||
|
User confirmed this was unintentional. Two files to update:
|
||||||
|
|
||||||
|
- [scripts/attendance.py:32-35](scripts/attendance.py#L32-L35) — uncomment
|
||||||
|
the two mappings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADULT_MERGED_MONTHS = {
|
||||||
|
"2025-12": "2026-01", # keys are merged into values
|
||||||
|
"2025-09": "2025-10",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [go/internal/services/membership/sources.go:30](go/internal/services/membership/sources.go#L30) — mirror the same:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var AdultMergedMonths = map[string]string{
|
||||||
|
"2025-12": "2026-01",
|
||||||
|
"2025-09": "2025-10",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this change, hit `POST /flush-cache` on each backend so the in-process
|
||||||
|
post-processed adults cache is rebuilt with the new mapping.
|
||||||
|
|
||||||
|
### 3. (Optional, separate) Fix the JS auto-default that hides older months
|
||||||
|
|
||||||
|
This is **not** the cause of the user's current symptom (that's the sheet
|
||||||
|
issue), but it will become a UX issue once Sep/Oct/Nov columns are restored:
|
||||||
|
the Python frontend's `defaultFrom = Math.max(0, maxMonthIdx - 4)` will still
|
||||||
|
default the From-selector to ~5 months before the latest column on every page
|
||||||
|
load, hiding restored older months until the user manually picks them.
|
||||||
|
|
||||||
|
- [templates/adults.html:1047](templates/adults.html#L1047) — `var defaultFrom = Math.max(0, maxMonthIdx - 4);`
|
||||||
|
- [templates/juniors.html:1028](templates/juniors.html#L1028) — same line.
|
||||||
|
|
||||||
|
Drop those four lines (`defaultFrom`, `fromSelect.value = defaultFrom`,
|
||||||
|
`toSelect.value = maxMonthIdx`, `applyMonthFilter()`) so the page loads with
|
||||||
|
all non-future months visible — matching the Go side, which only calls
|
||||||
|
`hideFutureMonths()` and leaves From at its first option.
|
||||||
|
|
||||||
|
Recommend bundling this with step 2 since they touch related UI.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **After step 1** — `POST /flush-cache` on Python and Go backends; reload
|
||||||
|
`/adults` on each. Confirm the dropdown now lists Sep/Oct/Nov 2025.
|
||||||
|
2. **After step 2** — reload `/adults`. Confirm the dropdown shows
|
||||||
|
"Sep+Oct 2025" as a single period and "Dec 2025+Jan 2026" as a single
|
||||||
|
period. (Still requires the sheet columns to exist.)
|
||||||
|
3. **After step 3** — reload `/adults` and `/juniors` on Python. Confirm the
|
||||||
|
table renders all non-future months on first load (Sep 2025 through the
|
||||||
|
current month) instead of starting at Dec 2025.
|
||||||
|
4. **Parity check** — `make parity` should report zero diffs between Python
|
||||||
|
and Go on `/api/adults` and `/api/juniors`.
|
||||||
|
|
||||||
|
## Critical files referenced
|
||||||
|
|
||||||
|
- `tmp/go/attendance_regular_cache.json` — current adults sheet rows
|
||||||
|
(evidence: header starts at `02.12.2025`).
|
||||||
|
- `tmp/go/attendance_juniors_cache.json` — current juniors sheet rows
|
||||||
|
(header still has `15.09.2025`).
|
||||||
|
- [scripts/attendance.py](scripts/attendance.py) — `ADULT_MERGED_MONTHS`
|
||||||
|
empty after `1257f0d`; `parse_dates` / `group_by_month` faithful.
|
||||||
|
- [go/internal/services/membership/sources.go](go/internal/services/membership/sources.go) — Go counterpart, same shape.
|
||||||
|
- [templates/adults.html](templates/adults.html), [templates/juniors.html](templates/juniors.html) — JS onload `defaultFrom = -4` issue (step 3).
|
||||||
|
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — Go filter UI (already correct, no changes).
|
||||||
67
docs/plans/2026-05-08-1123-go-m6-3-juniors-page.md
Normal file
67
docs/plans/2026-05-08-1123-go-m6-3-juniors-page.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# M6.3 — Go-native `/juniors` page
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go rewrite is in milestone M6 (Go-native HTML frontend). M6.2 shipped the `/adults` page in commit `c85748b`; the [progress tracker](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) line 115 names M6.3 as:
|
||||||
|
|
||||||
|
> `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||||
|
|
||||||
|
The Go side already has `buildJuniorsResponse` (M5.2) that produces the full JSON view model, and the JuniorsResponse uses the same `MemberRow`/`MonthCell`/`TotalCell` types as `AdultsResponse`. Critically, `buildJuniorMemberRow` ([go/internal/web/api/build_juniors.go:142](../../srv/personal/fuj-management/go/internal/web/api/build_juniors.go#L142)) already bakes the J/A breakdown (e.g. `"3/500 CZK (4:2J,2A)"`) and the `"?"` sentinel into `MonthCell.Text` — no template-level junior-specific logic is required for the M6.3 table.
|
||||||
|
|
||||||
|
Today `juniors.tmpl` is a 4-line "Coming in M6.3" placeholder and `HTMLHandler.ServeJuniors` ([html_handler.go:35](../../srv/personal/fuj-management/go/internal/web/html_handler.go#L35)) just renders that placeholder. Goal of M6.3: produce a working `/juniors` page that mirrors the structure of `/adults` and reaches feature parity with the Python `/juniors` route.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Mirror the M6.2 pattern exactly:
|
||||||
|
|
||||||
|
1. **Extract `AssembleJuniors(ctx)`** from the JSON handler so HTML + JSON share one view-model assembly path (the parity contract).
|
||||||
|
2. **Add `JuniorsPageData`** wrapper type next to `AdultsPageData` in `render.go`.
|
||||||
|
3. **Replace `juniors.tmpl`** with an adults-shaped layout. Because `JuniorsResponse` shares the same field names used by `adults.tmpl` (`Months`, `RawMonths`, `Results`, `Totals`, `Credits`, `Debts`, `AttendanceURL`, `PaymentsURL`, `BankAccount`, `CurrentMonth`), the template body is essentially copy/paste with one parity tweak (see "Decisions" below).
|
||||||
|
4. **Wire `HTMLHandler.ServeJuniors`** to call `AssembleJuniors` and render the new template, parallel to `ServeAdults`.
|
||||||
|
|
||||||
|
No new CSS, no new JS — `filters.js` and the `cell-*` classes (lifted in M6.1) already cover juniors.
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| [go/internal/web/api/handler.go](../../srv/personal/fuj-management/go/internal/web/api/handler.go) | Add `AssembleJuniors(ctx) (JuniorsResponse, error)`; refactor existing `ServeJuniors` to call it (mirrors `AssembleAdults` at line 47). |
|
||||||
|
| [go/internal/web/render.go](../../srv/personal/fuj-management/go/internal/web/render.go) | Add `JuniorsPageData` struct (PageData + `Data api.JuniorsResponse` + `Error string`). |
|
||||||
|
| [go/internal/web/html_handler.go](../../srv/personal/fuj-management/go/internal/web/html_handler.go) | Replace stub `ServeJuniors` with the same shape as `ServeAdults` (lines 20–33). |
|
||||||
|
| [go/internal/web/templates/juniors.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/juniors.tmpl) | Replace placeholder with a structurally identical copy of [adults.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/adults.tmpl), with the title swapped to "Juniors Dashboard" and the Unmatched section removed (parity with Python). |
|
||||||
|
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) | Tick `M6.3` once merged, append merge SHA. |
|
||||||
|
| [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) | New top-most entry per CLAUDE.md format. |
|
||||||
|
|
||||||
|
## Decisions captured
|
||||||
|
|
||||||
|
- **No "Unmatched Transactions" section on `/juniors`.** Python's [templates/juniors.html](../../srv/personal/fuj-management/templates/juniors.html) renders only Credits + Debts (no Unmatched block). Although `JuniorsResponse.Unmatched` is populated, parity says we don't render it on juniors. The data stays available for the modal in M6.5.
|
||||||
|
- **Reuse `qrHref` / `qrHrefAll` template funcs** from [render.go:35–57](../../srv/personal/fuj-management/go/internal/web/render.go#L35-L57). They are name-only (not adult-specific) and already on `tmplFuncs`.
|
||||||
|
- **Reuse the existing `cell-empty / cell-unpaid / cell-unpaid-current / cell-ok / cell-overridden` cell-class branching** from `adults.tmpl` lines 60–67 verbatim. The "?" sentinel is rendered as plain `{{$cell.Text}}` text — no template branch needed because `buildJuniorMemberRow` produces `"?"` or `"? CZK"` already.
|
||||||
|
- **No template helper extraction** (e.g. shared `_table_section.tmpl`). The two templates will be near-duplicates, but extracting a shared partial now is premature — wait until M6.4 or later when patterns settle. M6.2 didn't extract one either.
|
||||||
|
- **Branch**: `feat/go-m6-3-juniors-page`, MR via `tea pr create` per CLAUDE.md.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end smoke (parallel-running both backends as in M5/M6.2):
|
||||||
|
|
||||||
|
1. `cd go && make run` (or whatever target boots the Go server on :8080).
|
||||||
|
2. Browser-load `http://localhost:8080/juniors`. Confirm:
|
||||||
|
- Member rows render with cells containing fee + attendance breakdown like `0/500 CZK (3:2J,1A)`.
|
||||||
|
- At least one `"?"` cell visible (any junior with exactly one session in some month).
|
||||||
|
- Filter input narrows rows by name; From/To month selects hide columns via `data-month-idx`.
|
||||||
|
- `Pay` button visible on past-month unpaid cells; clicking links to `/qr?...`. (The QR endpoint itself lands in M6.6 — only the link target is in scope here.)
|
||||||
|
- `Pay All` visible on rows with `Balance < 0`.
|
||||||
|
- TOTAL row sums correctly per month.
|
||||||
|
- Credits + Debts sections render when present; no Unmatched section.
|
||||||
|
3. JSON parity: `make parity` against the M3 fixture corpus must still report zero non-allowlisted diffs for `/api/juniors` (the `AssembleJuniors` extraction must not change wire output).
|
||||||
|
4. Compare side-by-side against Python `/juniors` on :5001 for the same fixture; the table cells should match cell-for-cell modulo the known M5 allowlist.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Modal (`[i]` info button + `#memberModal`) — that is M6.5.
|
||||||
|
- QR endpoint, `/sync-bank`, `/flush-cache`, `/version` pages — M6.6.
|
||||||
|
- Embed.FS deploy verification — M6.7.
|
||||||
|
|
||||||
|
## Plan-file relocation
|
||||||
|
|
||||||
|
Per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) "Plans" section, plan files belong in `docs/plans/YYYY-MM-DD-HHMM-<slug>.md` inside the repo. Plan mode forced this draft into `~/.claude/plans/`; first step after ExitPlanMode is to copy this file to `docs/plans/2026-05-08-HHMM-go-m6-3-juniors-page.md` (resolving the timestamp at that moment) and commit it on the feature branch alongside the implementation.
|
||||||
215
docs/plans/2026-05-08-1220-go-m6-4-payments-page.md
Normal file
215
docs/plans/2026-05-08-1220-go-m6-4-payments-page.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# M6.4 — Go-native `/payments` page (grouped-by-person ledger)
|
||||||
|
|
||||||
|
> Plan-mode note: per project convention this plan should live at
|
||||||
|
> `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution
|
||||||
|
> step after approval is to copy this file there.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go port of the Flask app is at milestone **M6.4**: replace the placeholder
|
||||||
|
`/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl))
|
||||||
|
with a feature-equivalent grouped-by-person ledger view, matching the Python
|
||||||
|
[templates/payments.html](templates/payments.html) page.
|
||||||
|
|
||||||
|
Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally
|
||||||
|
much simpler**:
|
||||||
|
|
||||||
|
- No filters (no name input, no month-range selects).
|
||||||
|
- No totals / credits / debts / unmatched-as-extra-section.
|
||||||
|
- No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's
|
||||||
|
payments page itself has none).
|
||||||
|
- No QR / Pay buttons.
|
||||||
|
- Single grouping dimension: **by person**, sorted alphabetically, with a
|
||||||
|
synthetic `"Unmatched / Unknown"` bucket appended.
|
||||||
|
- Each person's transactions sorted **newest-first**.
|
||||||
|
- Columns: Date, Amount (right-aligned, green), Purpose, Bank Message.
|
||||||
|
|
||||||
|
The JSON API side is already done (`/api/payments` at
|
||||||
|
[go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)),
|
||||||
|
returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL,
|
||||||
|
PaymentsURL }`. The HTML side just needs to consume it.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed
|
||||||
|
`PageData` wrapper, render a real template, lift the page-specific CSS into the
|
||||||
|
shared stylesheet.
|
||||||
|
|
||||||
|
### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)`
|
||||||
|
|
||||||
|
In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor
|
||||||
|
`ServePayments` (currently inlines the load+build at lines 78-86) to mirror
|
||||||
|
`AssembleAdults` / `AssembleJuniors`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.AssemblePayments(r.Context())
|
||||||
|
if err != nil { h.writeError(w, r, err); return }
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
|
||||||
|
txns, err := h.Sources.LoadTransactions(ctx)
|
||||||
|
if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) }
|
||||||
|
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pure refactor — `make parity` should report zero diffs on `/api/payments`.
|
||||||
|
|
||||||
|
### 2. Add `PaymentsPageData` wrapper
|
||||||
|
|
||||||
|
In [go/internal/web/render.go](go/internal/web/render.go), alongside
|
||||||
|
`AdultsPageData` / `JuniorsPageData`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PaymentsPageData struct {
|
||||||
|
PageData
|
||||||
|
Data api.PaymentsResponse
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes needed to `pageNames` — `"payments"` is already registered.
|
||||||
|
|
||||||
|
### 3. Rewire HTML handler
|
||||||
|
|
||||||
|
Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52):
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} }
|
||||||
|
resp, err := h.apiHandler.AssemblePayments(r.Context())
|
||||||
|
if err != nil { data.Error = err.Error() } else { data.Data = resp }
|
||||||
|
h.renderer.Render(w, "payments", data)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Replace template stub
|
||||||
|
|
||||||
|
Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)
|
||||||
|
(currently a 5-line placeholder). Structure:
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
{{define "title"}}Payments Ledger{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Payments Ledger</h1>
|
||||||
|
<p class="description">
|
||||||
|
All bank transactions from the
|
||||||
|
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
|
||||||
|
grouped by member. Names are matched against the
|
||||||
|
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
|
||||||
|
</p>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<div class="ledger-container">
|
||||||
|
{{range $person := .Data.SortedPeople}}
|
||||||
|
<div class="member-block">
|
||||||
|
<h2>{{$person}}</h2>
|
||||||
|
<table class="txn-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="txn-date">Date</th>
|
||||||
|
<th class="txn-amount">Amount</th>
|
||||||
|
<th class="txn-purpose">Purpose</th>
|
||||||
|
<th class="txn-message">Bank Message</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $tx := index $.Data.GroupedPayments $person}}
|
||||||
|
<tr>
|
||||||
|
<td class="txn-date">{{$tx.Date}}</td>
|
||||||
|
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
|
||||||
|
<td class="txn-purpose">{{$tx.Purpose}}</td>
|
||||||
|
<td class="txn-message">{{$tx.Message}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints
|
||||||
|
the float as-is, but bank-row amounts are always whole CZK). If the user
|
||||||
|
prefers exact Python parity (`750.0 CZK`), drop the `printf` and use
|
||||||
|
`{{$tx.Amount}}`.
|
||||||
|
- Iterating a `map` requires using `.Data.SortedPeople` to drive order then
|
||||||
|
`index $.Data.GroupedPayments $person` — Go templates don't preserve map
|
||||||
|
insertion order.
|
||||||
|
|
||||||
|
### 5. Add CSS classes to `app.css`
|
||||||
|
|
||||||
|
Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164)
|
||||||
|
to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css):
|
||||||
|
`.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`,
|
||||||
|
`.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The
|
||||||
|
terminal-green palette already matches.
|
||||||
|
|
||||||
|
### 6. Unit test
|
||||||
|
|
||||||
|
Add a markup-level test to `go/internal/web/html_handler_test.go` (the file
|
||||||
|
created in M6.2/M6.3) following the same pattern: stub `Sources` returning a
|
||||||
|
fixed transaction list, assert response is HTML 200, assert key strings appear
|
||||||
|
(person names as `<h2>`, "Payments Ledger" `<h1>`, expected amount, "Unmatched
|
||||||
|
/ Unknown" bucket when an unattributed tx exists).
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|---------|------|
|
||||||
|
| edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` |
|
||||||
|
| edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` |
|
||||||
|
| edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` |
|
||||||
|
| rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template |
|
||||||
|
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS |
|
||||||
|
| edit | `go/internal/web/html_handler_test.go` — payments markup test |
|
||||||
|
|
||||||
|
No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`,
|
||||||
|
or any domain code — these were completed in M5 / M6.1.
|
||||||
|
|
||||||
|
## Reused existing helpers
|
||||||
|
|
||||||
|
- [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged)
|
||||||
|
- [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort
|
||||||
|
- [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames`
|
||||||
|
- [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end:
|
||||||
|
|
||||||
|
1. `cd go && make go-build go-test go-lint` — all green.
|
||||||
|
2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on
|
||||||
|
`/api/payments`.
|
||||||
|
3. `make web-go &` then:
|
||||||
|
- `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`.
|
||||||
|
- Browser load `http://localhost:8080/payments`:
|
||||||
|
- "Payments Ledger" heading + description with two clickable sheet links.
|
||||||
|
- Alphabetical member blocks, each with a table.
|
||||||
|
- Amounts right-aligned, green, "CZK" suffix.
|
||||||
|
- Newest-first ordering inside each block.
|
||||||
|
- "Unmatched / Unknown" bucket sorts in alphabetical position (capital U →
|
||||||
|
near end).
|
||||||
|
- Hover on a row darkens the background.
|
||||||
|
- Nav highlights `[Payments Ledger]` as the active link.
|
||||||
|
- Cross-check with Python: `make web-py &` → `http://localhost:5001/payments`
|
||||||
|
should look visually identical except for nav styling.
|
||||||
|
4. `make web-py & make web-go &` then `tea pr create` once committed (per
|
||||||
|
project branch-per-feature workflow).
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`.
|
||||||
|
Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user
|
||||||
|
confirms it works, tick **M6.4** in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116)
|
||||||
|
with the merge SHA after the user merges in Gitea.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either).
|
||||||
|
- **Period selector** — `/payments` has none in Python; not adding one.
|
||||||
|
- **embed.FS deploy verification** — M6.7.
|
||||||
|
- **Shared partial extraction** — payments structurally diverges from
|
||||||
|
adults/juniors enough that there's no obvious common partial to lift; defer.
|
||||||
288
docs/plans/2026-05-08-1259-go-m6-5-modal-js.md
Normal file
288
docs/plans/2026-05-08-1259-go-m6-5-modal-js.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# M6.5 — Member-detail modal JS module (`/adults`, `/juniors`)
|
||||||
|
|
||||||
|
> Plan-mode note: per project convention this plan should live at
|
||||||
|
> `docs/plans/2026-05-08-HHMM-go-m6-5-modal-js.md` (use `date "+%Y-%m-%d-%H%M"`
|
||||||
|
> when copying). The first execution step after approval is to copy this file
|
||||||
|
> there. Branch: `feat/go-m6-5-modal-js` off `main`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go port of the Flask app is at milestone **M6.5**: the `/adults` and
|
||||||
|
`/juniors` pages currently render their tables ([go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl),
|
||||||
|
[go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl))
|
||||||
|
but have no row-click member-detail modal — the feature that lets a user click
|
||||||
|
a member's row to inspect status per month, exception overrides, "other"
|
||||||
|
transactions, the matched payment list, and a debug raw-payments view.
|
||||||
|
|
||||||
|
The Python implementation lives inline in
|
||||||
|
[templates/adults.html:718-993](templates/adults.html#L718-L993)
|
||||||
|
(matching code in [templates/juniors.html](templates/juniors.html) — same
|
||||||
|
modal markup and JS). It uses globals injected via Jinja:
|
||||||
|
`memberData`, `sortedMonths`, `monthLabels`, `rawPaymentsByPerson`. M6.5
|
||||||
|
replaces this with a clean static JS module.
|
||||||
|
|
||||||
|
Per milestone wording — *"fetches `/api/adults` (or juniors), renders
|
||||||
|
status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)"* —
|
||||||
|
and per the user's design choice: the JS module fetches `/api/<page>` **once
|
||||||
|
on page load** and caches the response in a module-scoped variable. Subsequent
|
||||||
|
row clicks render synchronously from cache. This keeps HTML purely
|
||||||
|
server-rendered and reuses the parity-tested JSON contract already shipped in
|
||||||
|
M5.
|
||||||
|
|
||||||
|
All the building blocks are already in place:
|
||||||
|
|
||||||
|
- **JSON API**: `/api/adults` and `/api/juniors` already return
|
||||||
|
`member_data`, `month_labels`, `raw_payments` as nested objects (see
|
||||||
|
[go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42)
|
||||||
|
and the analogous juniors response). Junior `Expected` serialises as `"?"`
|
||||||
|
for the unknown sentinel via custom `MarshalJSON`
|
||||||
|
([go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29)) — JS sees a literal string `"?"`.
|
||||||
|
- **CSS**: `#memberModal`, `.modal-content`, `.modal-section`, `.modal-table`,
|
||||||
|
`.tx-list`, `.tx-item`, `.tx-meta`, `.tx-amount`, `.info-icon`, `.raw-toggle`
|
||||||
|
were lifted whole during M6.1 and live in
|
||||||
|
[go/internal/web/static/css/app.css](go/internal/web/static/css/app.css)
|
||||||
|
(lines 168-487). No CSS work needed.
|
||||||
|
- **Member-row hooks**: `tr.member-row[data-name="..."]` and `td.member-name`
|
||||||
|
are already rendered by both templates.
|
||||||
|
- **JS module convention**: `static/js/filters.js` is loaded with `<script src="/static/js/filters.js" defer>`;
|
||||||
|
M6.5 adds a sibling `static/js/member-detail.js` with the same loading
|
||||||
|
pattern.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. Add `[i]` info icon + `#memberModal` markup to both templates
|
||||||
|
|
||||||
|
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||||
|
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl):
|
||||||
|
|
||||||
|
a. Inside the `<td class="member-name">{{$row.Name}}</td>` cell (line 59 of
|
||||||
|
each template), append the icon button:
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||||
|
```
|
||||||
|
|
||||||
|
Using a `data-name` attribute and a CSS class (rather than `onclick="..."`)
|
||||||
|
keeps the template free of inline JS — the module wires up the listener
|
||||||
|
itself.
|
||||||
|
|
||||||
|
b. After the closing `</div>` of the table-container / sections, before the
|
||||||
|
`<script>` tag at line 140 (adults) / 120 (juniors), inject the modal
|
||||||
|
markup. Mirror the Python structure verbatim
|
||||||
|
([templates/adults.html:644-707](templates/adults.html#L644-L707)):
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<div id="memberModal" class="modal" onclick="if (event.target === this) this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="modalMemberName">—</span>
|
||||||
|
<span id="modalTier" class="modal-tier"></span>
|
||||||
|
<a href="#" class="modal-close" onclick="event.preventDefault(); document.getElementById('memberModal').classList.remove('active')">[x]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status by Month</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead><tr><th>Month</th><th>Sessions</th><th>Expected</th><th>Paid</th><th>Status</th></tr></thead>
|
||||||
|
<tbody id="modalStatusBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display:none">
|
||||||
|
<div class="modal-section-title">Exception Overrides</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display:none">
|
||||||
|
<div class="modal-section-title">Other Payments</div>
|
||||||
|
<div id="modalOtherList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Matched Transactions</div>
|
||||||
|
<div id="modalTxList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">
|
||||||
|
Raw Payments (debug)
|
||||||
|
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||||
|
</div>
|
||||||
|
<div id="modalRawList" class="tx-list" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
c. Add the script tag alongside `filters.js`. To tell the module which API
|
||||||
|
endpoint to fetch, use a `data-page` attribute on `body` (or on the
|
||||||
|
filter container — already exists). Simplest: add `data-page="adults"` /
|
||||||
|
`data-page="juniors"` to the existing `<div id="filterContainer">`.
|
||||||
|
The module reads it on load.
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js)
|
||||||
|
|
||||||
|
A single shared module — both pages use the same DOM contract, so the same
|
||||||
|
JS works for both. Structure (vanilla ES, no build step, matches `filters.js`
|
||||||
|
style — IIFE, `'use strict'`, no globals leaked):
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const page = container.dataset.page; // "adults" | "juniors"
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
let apiData = null; // cached /api/<page> response
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
// ── Data load ─────────────────────────────────────────────────────────
|
||||||
|
async function loadData() {
|
||||||
|
if (apiData) return apiData;
|
||||||
|
const r = await fetch('/api/' + page);
|
||||||
|
if (!r.ok) throw new Error('failed to fetch /api/' + page);
|
||||||
|
apiData = await r.json();
|
||||||
|
return apiData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-warm immediately so first click is instant.
|
||||||
|
loadData().catch(err => console.error('[member-detail]', err));
|
||||||
|
|
||||||
|
// ── Modal render ──────────────────────────────────────────────────────
|
||||||
|
async function showMember(name) { /* port of Python showMemberDetails */ }
|
||||||
|
function toggleRawPayments(ev) { /* port */ }
|
||||||
|
function closeModal() { document.getElementById('memberModal').classList.remove('active'); }
|
||||||
|
function navigateMember(dir) { /* port — walk visible .member-row */ }
|
||||||
|
|
||||||
|
// ── Wiring ────────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.info-icon[data-name]').forEach(el => {
|
||||||
|
el.addEventListener('click', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
showMember(el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
const modal = document.getElementById('memberModal');
|
||||||
|
if (e.key === 'Escape') { closeModal(); return; }
|
||||||
|
if (!modal.classList.contains('active')) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
```
|
||||||
|
|
||||||
|
The four `port` functions transcribe Python literally, with two adjustments:
|
||||||
|
|
||||||
|
- `data.tier` — read directly from the API response (already present as
|
||||||
|
`AdultsMemberData.Tier` / `JuniorsMemberData.Tier`).
|
||||||
|
- Junior `expected` may equal the literal string `"?"` (sentinel from
|
||||||
|
`Expected.MarshalJSON`). The status-row formatter must treat string
|
||||||
|
`expected` as "unknown / single attendance" and skip the numeric
|
||||||
|
comparisons — same logic Python uses when it sees `"?"`.
|
||||||
|
|
||||||
|
`navigateMember` walks `document.querySelectorAll('tr.member-row')` filtered
|
||||||
|
by `style.display !== 'none'`, finds the index whose `dataset.name`
|
||||||
|
matches `currentMemberName`, and calls `showMember` on the next/previous
|
||||||
|
visible row. Identical to Python lines 944-962 but uses `dataset.name`
|
||||||
|
instead of parsing `childNodes[0].textContent`.
|
||||||
|
|
||||||
|
### 3. Wire `data-page` attribute
|
||||||
|
|
||||||
|
Append `data-page="adults"` to the `filterContainer` div in `adults.tmpl`,
|
||||||
|
`data-page="juniors"` in `juniors.tmpl`. One-line edit per template.
|
||||||
|
|
||||||
|
### 4. Unit test
|
||||||
|
|
||||||
|
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||||
|
with assertions that the rendered HTML for `/adults` and `/juniors` contains:
|
||||||
|
|
||||||
|
- `class="info-icon"` with the expected `data-name` per fixture row,
|
||||||
|
- `id="memberModal"`,
|
||||||
|
- `<script src="/static/js/member-detail.js"`,
|
||||||
|
- `data-page="adults"` / `data-page="juniors"` on the filter container.
|
||||||
|
|
||||||
|
These are markup-level checks only — the JS module behaviour is not unit
|
||||||
|
tested in Go (would require headless-browser tooling that is out of scope
|
||||||
|
for this milestone). Manual browser verification covers it.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|--------|------|
|
||||||
|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — add `[i]` icon, modal markup, `data-page`, script tag |
|
||||||
|
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||||
|
| new | `go/internal/web/static/js/member-detail.js` — modal module |
|
||||||
|
| edit | `go/internal/web/html_handler_test.go` — markup assertions |
|
||||||
|
|
||||||
|
No changes to: `app.css` (already complete), `assets.go` (the `embed.FS`
|
||||||
|
glob `static/js/*` already picks up new files), `server.go`, `render.go`,
|
||||||
|
the API handlers, or any domain code.
|
||||||
|
|
||||||
|
## Reused existing infrastructure
|
||||||
|
|
||||||
|
- [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — `/api/adults`, `/api/juniors` already serve the modal's data
|
||||||
|
- [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42), [go/internal/web/api/juniors.go](go/internal/web/api/juniors.go) — typed responses
|
||||||
|
- [go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29) — `Expected` "?" sentinel marshalling
|
||||||
|
- [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — modal CSS already lifted
|
||||||
|
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — IIFE / `'use strict'` / `data-*` attribute style to match
|
||||||
|
- [templates/adults.html:725-962](templates/adults.html#L725-L962) — Python reference implementation to port (logic, not literal copy)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end:
|
||||||
|
|
||||||
|
1. `cd go && make go-build go-test go-lint` — all green.
|
||||||
|
2. `make web-go &` — boot Go on `:8080`.
|
||||||
|
3. Browser: `http://localhost:8080/adults`
|
||||||
|
- Click `[i]` next to a member name → modal opens with that member's data.
|
||||||
|
- Status table populated; cells colour-coded (cell-ok / cell-unpaid).
|
||||||
|
- Exception section visible only if member has exceptions; exception
|
||||||
|
amount shown with "*" override style.
|
||||||
|
- "Other Payments" section visible only if member has other-purpose
|
||||||
|
transactions.
|
||||||
|
- Matched transactions list newest-first; raw-payments section starts
|
||||||
|
hidden, `[show]` toggles to `[hide]`.
|
||||||
|
- `Esc` closes the modal; `ArrowDown` / `ArrowUp` walk visible rows
|
||||||
|
(after applying name filter).
|
||||||
|
- Click outside the `.modal-content` closes the modal.
|
||||||
|
- Open DevTools Network tab → reload; confirm one request to `/api/adults`
|
||||||
|
fires on page load (data is pre-warmed), no further requests on row
|
||||||
|
click.
|
||||||
|
4. Browser: `http://localhost:8080/juniors`
|
||||||
|
- Same checks; verify `expected = "?"` rows render the question-mark
|
||||||
|
status without throwing JS errors.
|
||||||
|
5. Cross-check against Python: `make web-py &` → `http://localhost:5001/adults`
|
||||||
|
should look visually equivalent (modulo nav styling) for the same fixture.
|
||||||
|
6. After commit: `tea pr create --base main --head feat/go-m6-5-modal-js`.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-5-modal-js` off `main`,
|
||||||
|
commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||||
|
`tea pr create`. Do not merge from CLI. After the user merges in Gitea:
|
||||||
|
|
||||||
|
- Add CHANGELOG entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||||
|
- Tick **M6.5** in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:117](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L117)
|
||||||
|
with the merge SHA.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- **`/qr` modal** — separate `#qrModal` element, lives in M6.6 alongside
|
||||||
|
`/sync-bank`, `/flush-cache`, `/version`.
|
||||||
|
- **Pay buttons inside the modal** — Python's modal does not include
|
||||||
|
Pay-from-modal; current Go pages already render row-level Pay buttons
|
||||||
|
in M6.2/M6.3.
|
||||||
|
- **Modal on `/payments`** — Python `/payments` has no modal, neither does
|
||||||
|
Go (confirmed in M6.4 plan).
|
||||||
|
- **Deep-linking (`/adults#name=Foo`)** — not in Python, not adding.
|
||||||
|
- **Headless-browser JS tests** — out of scope for this milestone; manual
|
||||||
|
browser verification per §Verification.
|
||||||
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# M6.6 — `/qr`, `/sync-bank`, `/flush-cache`, `/version` (Go parity)
|
||||||
|
|
||||||
|
> On approval, relocate this file to `docs/plans/YYYY-MM-DD-HHMM-go-m6-6-action-pages.md` per the repo's `CLAUDE.md` "Plans" convention before starting work.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go-rewrite progress tracker ([2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118)) lists M6.6 as:
|
||||||
|
|
||||||
|
> `/qr, /sync-bank, /flush-cache, /version pages`
|
||||||
|
|
||||||
|
After M6.4 (payments page) and M6.5 (member-detail modal JS), the only remaining pre-deployment HTML/action endpoints in Python that the Go binary doesn't yet implement are these four. The adults/juniors templates already produce `/qr?...` URLs via the `qrHref` / `qrHrefAll` helpers ([render.go:47-71](go/internal/web/render.go#L47-L71)) — the matching handler is the missing piece. `sync` and `flush_cache` templates exist as "Coming in M6.6" stubs and need real markup + working handlers. `/api/version` already serves build metadata; `/version` just needs to alias it (Python parity).
|
||||||
|
|
||||||
|
After M6.6 the M6 gate ("Browser smoke on :8080: all pages render … QR loads, sync/flush work end-to-end") is reachable.
|
||||||
|
|
||||||
|
## Confirmed design choices
|
||||||
|
|
||||||
|
- **`/sync-bank`**: Python-faithful — GET both renders and triggers the sync, captures stdout/stderr into a log buffer, surfaces success/failure via a flag. Matches existing nav `[Sync Bank Data]` link.
|
||||||
|
- **`/version`**: JSON alias of `/api/version`. No HTML page.
|
||||||
|
- **QR library**: `github.com/skip2/go-qrcode`. Default level `qrcode.Medium`, size 256.
|
||||||
|
|
||||||
|
## Scope per endpoint
|
||||||
|
|
||||||
|
### 1. `GET /qr` — PNG only
|
||||||
|
|
||||||
|
Match Python ([app.py:321-356](app.py#L321-L356)) verbatim semantics:
|
||||||
|
|
||||||
|
1. Read `account` (default `cfg.BankAccount`), `amount` (default `"0"`), `message` (default `""`).
|
||||||
|
2. Validate account against `^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`; on miss, silently fall back to `cfg.BankAccount`.
|
||||||
|
3. Parse amount as float64; clamp to `[0, 10_000_000]`; format `%.2f`; on parse error → `"0.00"`.
|
||||||
|
4. Truncate message to 60 runes; strip all `*`.
|
||||||
|
5. If account contains `/`, split into `{number}*BC:{bankcode}`; else use raw.
|
||||||
|
6. SPD payload: `SPD*1.0*ACC:{acc}*AM:{amount}*CC:CZK*MSG:{msg}` (no `BC:` key when IBAN — Python doesn't either).
|
||||||
|
7. `qrcode.Encode(payload, qrcode.Medium, 256)` → write bytes with `Content-Type: image/png`.
|
||||||
|
|
||||||
|
No template, no HTML.
|
||||||
|
|
||||||
|
### 2. `GET /sync-bank` — triggers + renders
|
||||||
|
|
||||||
|
Match Python ([app.py:124-151](app.py#L124-L151)):
|
||||||
|
|
||||||
|
1. Build a `*bytes.Buffer` to capture step output.
|
||||||
|
2. For each step write a header line then call:
|
||||||
|
- `banksync.SyncToSheets(ctx, cfg.PaymentsSheetID, fioClient, sheetsClient, banksync.SyncOpts{From: jan1ThisYear, To: dec31ThisYear, Sort: true})`
|
||||||
|
- `banksync.InferPayments(ctx, cfg.PaymentsSheetID, sheetsClient, sources, banksync.InferOpts{})`
|
||||||
|
- `cacheFlusher.Flush()` — append `"%d cache files deleted"`.
|
||||||
|
3. On any error: append `err.Error()` + a stack trace via `runtime/debug.Stack()`, set `success=false`, continue rendering.
|
||||||
|
4. Render `sync` template with `SyncPageData{ PageData; Output string; Success bool }`.
|
||||||
|
|
||||||
|
The `banksync` package's stdout-printing functions can be redirected by passing an `io.Writer` — confirm during implementation; if they only print to `os.Stdout`, capture by tee'ing through a `slog` handler or briefly swapping `os.Stdout` (or extend the package with a `*Writer` option). **Preferred**: add a `Logger io.Writer` field to `banksync.SyncOpts` / `InferOpts`. This is a minor extension to existing services, not a rewrite.
|
||||||
|
|
||||||
|
### 3. `/flush-cache` — GET form, POST action
|
||||||
|
|
||||||
|
Match Python ([app.py:117-122](app.py#L117-L122)):
|
||||||
|
|
||||||
|
- `GET` → render `flush_cache` with `Flushed=false`.
|
||||||
|
- `POST` → call `cacheFlusher.Flush()`, render with `Flushed=true, Deleted=n`.
|
||||||
|
|
||||||
|
### 4. `GET /version` — JSON
|
||||||
|
|
||||||
|
One-liner: register the existing `api.VersionHandler` (or its inline equivalent in [api/version.go](go/internal/web/api/version.go)) on the `/version` path as well as `/api/version`. Output: `{"tag", "commit", "build_date"}`.
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- **[go/internal/web/templates/qr.tmpl](go/internal/web/templates/qr.tmpl)** — *not actually needed*; remove from plan if `/qr` is PNG-only. (Decision: skip — no template.)
|
||||||
|
- Replace stub **[go/internal/web/templates/sync.tmpl](go/internal/web/templates/sync.tmpl)** with full markup: title, status banner colored by `Success`, `<pre>` of `Output`, "Run again" link.
|
||||||
|
- Replace stub **[go/internal/web/templates/flush_cache.tmpl](go/internal/web/templates/flush_cache.tmpl)** with: form (`POST /flush-cache` → `<button>[Flush Cache]</button>`), success banner when `Flushed`.
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- **[go/go.mod](go/go.mod)** / `go.sum` — add `github.com/skip2/go-qrcode`.
|
||||||
|
- **[go/internal/web/server.go](go/internal/web/server.go)** — register `GET /qr`, `GET /sync-bank` (replace stub), `GET /flush-cache` (form), `POST /flush-cache` (action), `GET /version`. Widen `Run(...)` signature to accept `fio.Client`, `sheets.Client`, and a `cache.Flusher` — these flow into a new `Deps` struct on `HTMLHandler`.
|
||||||
|
- **[go/internal/web/html_handler.go](go/internal/web/html_handler.go)** — add `ServeQR`, real `ServeSync`, split `ServeFlushCacheGET` / `ServeFlushCachePOST`, add `ServeVersion` (delegates to api package).
|
||||||
|
- **[go/internal/web/render.go](go/internal/web/render.go)** — add `SyncPageData{ PageData; Output string; Success bool }` and `FlushPageData{ PageData; Flushed bool; Deleted int }` view-models. (`pageNames` already includes `"sync"` and `"flush_cache"`; no change.)
|
||||||
|
- **[go/internal/web/qr.go](go/internal/web/qr.go)** *(new file)* — pure helper: `BuildSPD(account, amount, message string, defaultAccount string) (payload string)` + `RenderQRCode(payload string) ([]byte, error)`. Easier to unit-test than a method on the handler.
|
||||||
|
- **[go/internal/services/membership/loader.go](go/internal/services/membership/loader.go)** — add a small `CacheFlusher` interface (`Flush() (int, error)`); have `Sources` aggregate include it. *Alternative considered*: pass `*cache.FileCache` straight into `web.Run`. Choose whichever needs fewer test-stub edits — likely the direct-pass approach, since stubs in tests don't need cache behavior. **Decision: pass `cache.Flusher` directly into `web.Run` as a separate dependency, not through `Sources`.** Simpler, fewer interface changes.
|
||||||
|
- **[go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)** + `infer.go` — accept an optional `io.Writer` (in `SyncOpts.Logger` / `InferOpts.Logger`) and route progress prints through it; default to `os.Stdout` when nil for CLI parity.
|
||||||
|
- **[go/cmd/fuj/main.go](go/cmd/fuj/main.go)** — in the `server` subcommand, build `fio.Client`, `sheets.Client`, and `cache.Flusher` (already constructed inside `membership.NewSources` — expose it or construct a sibling) and pass them into `web.Run`.
|
||||||
|
|
||||||
|
## Reuse (already in the codebase)
|
||||||
|
|
||||||
|
- **Bank sync**: `banksync.SyncToSheets` ([go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)) and `banksync.InferPayments` ([go/internal/services/banksync/infer.go](go/internal/services/banksync/infer.go)) — same calls the CLI `sync` / `infer` subcommands use ([go/cmd/fuj/main.go:184](go/cmd/fuj/main.go#L184), [:222](go/cmd/fuj/main.go#L222)).
|
||||||
|
- **Cache flush**: `(*cache.FileCache).Flush() (int, error)` ([go/internal/io/cache/filecache.go:92](go/internal/io/cache/filecache.go#L92)) — already returns the deleted count.
|
||||||
|
- **Build metadata**: `BuildInfo{Version, Commit, BuildDate}` ([go/internal/web/server.go:28-35](go/internal/web/server.go#L28-L35)) is already plumbed everywhere; `/api/version` JSON shape is already correct ([go/internal/web/api/version.go](go/internal/web/api/version.go)).
|
||||||
|
- **Template render pattern**: `Renderer.Render(w, name, data)` ([go/internal/web/render.go:98-108](go/internal/web/render.go#L98-L108)).
|
||||||
|
- **Test scaffolding**: `web.NewRenderer()` + `httptest.NewRequest/NewRecorder` ([go/internal/web/html_handler_test.go:52-94](go/internal/web/html_handler_test.go#L52-L94)).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Add to `go/internal/web/html_handler_test.go` (and one new `qr_test.go` for the SPD builder):
|
||||||
|
|
||||||
|
- **`TestQRBuildSPD`** *(unit, in [go/internal/web/qr_test.go](go/internal/web/qr_test.go))* — table-driven: invalid account → fallback; `/`-account → `{num}*BC:{bc}`; IBAN → raw; amount `-1` → `0.00`; amount `99999999` → `9999999.00`; amount `"abc"` → `0.00`; message with `*` stripped; message length-clamped to 60.
|
||||||
|
- **`TestServeQR`** — request `/qr?account=…&amount=10&message=Hi`; assert 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG\r\n\x1a\n`.
|
||||||
|
- **`TestServeFlushCacheGet`** — assert form rendered, no banner.
|
||||||
|
- **`TestServeFlushCachePost`** — fixture flusher returns 3; assert banner says "3" and `Flushed`.
|
||||||
|
- **`TestServeSync`** — fixture banksync stubs return `(2, nil)` and `(1, nil)`, fixture flusher returns 4; assert page renders, status is success, output contains the section headers.
|
||||||
|
- **`TestServeVersion`** — assert `application/json`, body `{"tag":"v0","commit":"abc1234","build_date":"2026-01-01"}`.
|
||||||
|
- Extend the `TestHTMLHandlerSmoke` table to cover `/qr` and `/version`.
|
||||||
|
|
||||||
|
## Verification (end-to-end)
|
||||||
|
|
||||||
|
1. `cd go && go build ./... && go test ./...`
|
||||||
|
2. `cd go && go run ./cmd/fuj server` — open http://localhost:8080
|
||||||
|
3. Smoke:
|
||||||
|
- **/adults** → click `Pay` next to any unpaid month → modal shows QR image (this exercises `/qr`). Confirm a Czech banking app can scan it (visual check).
|
||||||
|
- Direct `/qr?account=2702008874/2010&amount=700&message=Test` → PNG renders.
|
||||||
|
- **/sync-bank** → click `[Sync Bank Data]` in nav → page shows "Syncing…", then sections + "Inferred Payments", "Cache Flushed", green status banner.
|
||||||
|
- **/flush-cache** → click `[Flush Cache]` → success banner with file count.
|
||||||
|
- **/version** in browser → JSON visible.
|
||||||
|
- Adults / juniors / payments still render (no regressions from `Run` signature change).
|
||||||
|
4. Run the parity diff: `go run ./cmd/parity` — `/api/version` must still match Python.
|
||||||
|
5. Append a CHANGELOG entry per `CLAUDE.md`; tick `M6.6` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118).
|
||||||
|
|
||||||
|
## Branch / MR
|
||||||
|
|
||||||
|
Per `CLAUDE.md` workflow: `feat/go-m6-6-action-pages` off `main`, push with `-u`, open with `tea pr create --base main --head feat/go-m6-6-action-pages`.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- Auth / CSRF protection on `/sync-bank` and `/flush-cache` — Python has none either; deferred to M8 hardening.
|
||||||
|
- M6.7 (single-binary embed verification) — separate ticket.
|
||||||
|
- Streaming progress for `/sync-bank` (currently buffered until completion, like Python).
|
||||||
|
- Configurable QR error-correction level / size.
|
||||||
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
|
||||||
|
|
||||||
|
> Plan-mode note: per project convention this plan should live at
|
||||||
|
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
|
||||||
|
> execution step after approval is to copy this file there.
|
||||||
|
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
|
||||||
|
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
|
||||||
|
`/version`. While doing it, the **client-side** half of the Pay flow was
|
||||||
|
collapsed from a modal-with-details into a plain `<a href="/qr?...">` —
|
||||||
|
clicking Pay now navigates the whole tab to the raw PNG.
|
||||||
|
|
||||||
|
The Python app instead opens an in-page `#qrModal` showing the QR image
|
||||||
|
*plus* labelled details (account, amount, message, title), then closes
|
||||||
|
the modal on outside-click or `[close]`
|
||||||
|
([templates/adults.html:631-648](templates/adults.html#L631-L648),
|
||||||
|
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
|
||||||
|
This is the UX the user expects from the dashboard — staying on the
|
||||||
|
table while displaying the QR is significantly more practical than
|
||||||
|
losing context to a fullscreen image.
|
||||||
|
|
||||||
|
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
|
||||||
|
`#qrModal`, identical in structure to the Python original, driven by a
|
||||||
|
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
|
||||||
|
convention from M6.5.
|
||||||
|
|
||||||
|
All the building blocks are already in place:
|
||||||
|
|
||||||
|
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
|
||||||
|
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
|
||||||
|
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
|
||||||
|
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
|
||||||
|
already produce the correct query string + Czech `MM/YYYY` format
|
||||||
|
for the `message=` param. They're useful for sanity (and for any
|
||||||
|
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
|
||||||
|
- **Bank account** is already exposed as `$.Data.BankAccount` to every
|
||||||
|
template that has a Pay button.
|
||||||
|
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
|
||||||
|
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
|
||||||
|
inline `onclick`, no globals leaked.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
|
||||||
|
|
||||||
|
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||||
|
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
|
||||||
|
(both have identical Pay markup at lines 65 and 72):
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<button type="button" class="pay-btn"
|
||||||
|
data-name="{{$row.Name}}"
|
||||||
|
data-amount="{{$cell.Amount}}"
|
||||||
|
data-month="{{$cell.Month}}"
|
||||||
|
data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<button type="button" class="pay-btn"
|
||||||
|
data-name="{{$row.Name}}"
|
||||||
|
data-amount="{{$row.PayableAmount}}"
|
||||||
|
data-month="{{$row.UnpaidPeriods}}"
|
||||||
|
data-raw-month="{{$row.RawUnpaidPeriods}}"
|
||||||
|
data-pay-all="1">Pay All</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why `<button type="button">` and not `<a>`: the click no longer
|
||||||
|
navigates — the JS handles it. `type="button"` prevents accidental form
|
||||||
|
submission. A no-JS fallback could be added later by re-introducing
|
||||||
|
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()` —
|
||||||
|
deferred until anyone actually asks (see §Out of scope).
|
||||||
|
|
||||||
|
`data-month` carries the human-readable period (e.g. `January 2026`,
|
||||||
|
or `January 2026 + February 2026` for Pay-All) which the modal title
|
||||||
|
displays. `data-raw-month` carries the machine format (`2026-01`,
|
||||||
|
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
|
||||||
|
message — same conversion Python's `showPayQR` does at
|
||||||
|
[templates/adults.html:966-968](templates/adults.html#L966-L968).
|
||||||
|
`data-pay-all` is purely informational; the JS doesn't actually need
|
||||||
|
it (the same code path handles single + multi-period), but keeping it
|
||||||
|
helps debugging and any future analytics.
|
||||||
|
|
||||||
|
### 2. Add `#qrModal` markup
|
||||||
|
|
||||||
|
Append after the `#memberModal` block in both `adults.tmpl` and
|
||||||
|
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
|
||||||
|
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
|
||||||
|
using `class="active"` toggling (the convention M6.5 picked) instead of
|
||||||
|
`style.display = 'block'`:
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The CSS already targets `#qrModal .modal-content` directly. To make the
|
||||||
|
"hidden by default / visible when `.active`" toggle behave the same
|
||||||
|
way `#memberModal` does, the existing CSS rules for `#memberModal`
|
||||||
|
(`display:none`, `#memberModal.active { display: flex; }`) need a
|
||||||
|
sibling pair for `#qrModal`. Audit `app.css` during implementation:
|
||||||
|
- if `#qrModal` already has both rules, no change.
|
||||||
|
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
|
||||||
|
- if it has neither, add both.
|
||||||
|
|
||||||
|
This is a 2-line CSS adjustment at most; tracked under §Critical files.
|
||||||
|
|
||||||
|
### 3. Bank account on the page
|
||||||
|
|
||||||
|
The modal needs the bank account string for the `qrAccount` label and
|
||||||
|
for building the `/qr?account=…` URL. Two clean options:
|
||||||
|
|
||||||
|
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
|
||||||
|
Per-button duplication, but trivially done in templates and keeps the JS
|
||||||
|
module's surface tiny.
|
||||||
|
|
||||||
|
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
|
||||||
|
(or on the existing `#filterContainer`).
|
||||||
|
|
||||||
|
**Decision: (b)** — single stamping site, follows the same pattern
|
||||||
|
M6.5 used for `data-page` on `#filterContainer`. Add
|
||||||
|
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
|
||||||
|
both templates (one-line edit each).
|
||||||
|
|
||||||
|
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
|
||||||
|
|
||||||
|
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
|
||||||
|
right after `member-detail.js` in both templates. Pure ES, no build
|
||||||
|
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
|
||||||
|
([templates/adults.html:963-986](templates/adults.html#L963-L986))
|
||||||
|
plus event wiring:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const bankAccount = container.dataset.bankAccount || '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('qrModal');
|
||||||
|
const titleEl = document.getElementById('qrTitle');
|
||||||
|
const imgEl = document.getElementById('qrImg');
|
||||||
|
const accountEl = document.getElementById('qrAccount');
|
||||||
|
const amountEl = document.getElementById('qrAmount');
|
||||||
|
const messageEl = document.getElementById('qrMessage');
|
||||||
|
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||||
|
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
|
||||||
|
// Mirrors templates/adults.html:966-968.
|
||||||
|
function toCzechMonth(rawMonth) {
|
||||||
|
return rawMonth.split('+')
|
||||||
|
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQR(name, amount, month, rawMonth) {
|
||||||
|
const numericMonth = toCzechMonth(rawMonth);
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
|
||||||
|
titleEl.innerText = `Payment for ${month}`;
|
||||||
|
accountEl.innerText = bankAccount;
|
||||||
|
amountEl.innerText = amount;
|
||||||
|
messageEl.innerText = message;
|
||||||
|
|
||||||
|
const url = '/qr?'
|
||||||
|
+ 'account=' + encodeURIComponent(bankAccount)
|
||||||
|
+ '&amount=' + encodeURIComponent(amount)
|
||||||
|
+ '&message=' + encodeURIComponent(message);
|
||||||
|
imgEl.src = url;
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQR() { modal.classList.remove('active'); }
|
||||||
|
|
||||||
|
// Event delegation: any .pay-btn click anywhere in the page.
|
||||||
|
document.addEventListener('click', ev => {
|
||||||
|
const btn = ev.target.closest('.pay-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
showQR(
|
||||||
|
btn.dataset.name,
|
||||||
|
btn.dataset.amount,
|
||||||
|
btn.dataset.month,
|
||||||
|
btn.dataset.rawMonth,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Event delegation** (single listener on `document`) handles both Pay
|
||||||
|
and Pay-All without enumerating buttons. It also survives any future
|
||||||
|
re-render of the table without needing rebinding.
|
||||||
|
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
|
||||||
|
for `Escape` too, but only acts when `#memberModal` is active; the
|
||||||
|
new module similarly only acts when `#qrModal` is active. No collision.
|
||||||
|
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
|
||||||
|
and only fires when the user clicks.
|
||||||
|
- **Inline `onclick`** on `[close]` and outside-click in the markup are
|
||||||
|
carried over from the M6.5 modal style; consistent with `#memberModal`.
|
||||||
|
(Switching the project's modal close pattern to data attributes is a
|
||||||
|
separate cleanup, out of scope here.)
|
||||||
|
|
||||||
|
### 5. Tests
|
||||||
|
|
||||||
|
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||||
|
with markup-level checks. Headless-browser JS is out of scope (same
|
||||||
|
rationale as M6.5).
|
||||||
|
|
||||||
|
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
|
||||||
|
|
||||||
|
- Body for `/adults` and `/juniors` contains
|
||||||
|
`<script src="/static/js/payment-qr.js"`.
|
||||||
|
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
|
||||||
|
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
|
||||||
|
- The fixture row's Pay button is rendered as
|
||||||
|
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
|
||||||
|
- The fixture row's `.pay-btn` does **not** include `href=`.
|
||||||
|
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
|
||||||
|
(matching the test's `fixtureHandler` config).
|
||||||
|
|
||||||
|
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
|
||||||
|
|
||||||
|
Once Pay/Pay-All buttons are JS-driven, the helpers in
|
||||||
|
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
|
||||||
|
**Decision: delete them**, plus their entries in `tmplFuncs`. A
|
||||||
|
no-JS fallback is not on the roadmap; if it ever becomes one, the
|
||||||
|
helpers are easy to resurrect from git.
|
||||||
|
|
||||||
|
The unit-test file currently has no direct tests for these helpers —
|
||||||
|
nothing to remove from tests.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|--------|------|
|
||||||
|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
|
||||||
|
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||||
|
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
|
||||||
|
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
|
||||||
|
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
|
||||||
|
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
|
||||||
|
|
||||||
|
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
|
||||||
|
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
|
||||||
|
API handlers, or any domain code.
|
||||||
|
|
||||||
|
## Reused existing infrastructure
|
||||||
|
|
||||||
|
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
|
||||||
|
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
|
||||||
|
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
|
||||||
|
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
|
||||||
|
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end, after copying the plan into `docs/plans/`:
|
||||||
|
|
||||||
|
1. `cd go && make go-build go-test go-lint` — all green.
|
||||||
|
2. `make web-go &` — boot Go on `:8080`.
|
||||||
|
3. Browser: `http://localhost:8080/adults`
|
||||||
|
- Click `Pay` on any unpaid month → `#qrModal` opens with
|
||||||
|
- title `Payment for <Month YYYY>`,
|
||||||
|
- `qrAccount` matching `BANK_ACCOUNT`,
|
||||||
|
- `qrAmount` matching the cell amount,
|
||||||
|
- `qrMessage` formatted as `<Name>: MM/YYYY`,
|
||||||
|
- QR image rendered from `/qr?…` (Network tab shows PNG response).
|
||||||
|
- Tab does **not** navigate; URL stays `/adults`.
|
||||||
|
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
|
||||||
|
- Click outside the modal-content → modal closes.
|
||||||
|
- Click `[close]` → modal closes.
|
||||||
|
- Press `Esc` → modal closes.
|
||||||
|
- Open `[i]` member-detail modal first, then `Esc` → only that closes
|
||||||
|
(verify both modals don't fight each other).
|
||||||
|
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
|
||||||
|
on a `"?"`-expected row doesn't appear (Pay button is conditional on
|
||||||
|
`unpaid`/`partial` cells).
|
||||||
|
5. Cross-check against Python: `make web-py &` →
|
||||||
|
`http://localhost:5001/adults` modal looks visually equivalent for
|
||||||
|
the same fixture (modulo any nav / theme differences already accepted
|
||||||
|
in M6).
|
||||||
|
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
|
||||||
|
payload starts with `SPD*1.0*ACC:` and the message decodes to
|
||||||
|
`<Name>: MM/YYYY`.
|
||||||
|
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
|
||||||
|
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||||
|
immediately under the M6.6 line, in the same format as the other
|
||||||
|
sub-bullets.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
|
||||||
|
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||||
|
`tea pr create`. Do not merge or delete from CLI.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
|
||||||
|
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
|
||||||
|
helpers if asked.
|
||||||
|
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
|
||||||
|
inline `onclick` for `[close]` and outside-click. Consistent with
|
||||||
|
M6.5 but worth a follow-up to delegate-event everything.
|
||||||
|
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
|
||||||
|
no one's asked for it on the Go side.
|
||||||
|
- **Per-button QR error-correction / size** — `/qr` is fixed at
|
||||||
|
Medium/256, same as M6.6. Configurable via query param can be added if
|
||||||
|
printing larger physical posters becomes a use case.
|
||||||
|
- **`/payments` Pay buttons** — the payments page is a ledger view; it
|
||||||
|
doesn't render Pay buttons in either backend.
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fuj-management/go/internal/services/banksync"
|
"fuj-management/go/internal/services/banksync"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -82,9 +83,40 @@ func serverCmd(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
||||||
|
|
||||||
|
actions := web.ActionHandlers{
|
||||||
|
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||||
|
yr := time.Now().Year()
|
||||||
|
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||||
|
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli,
|
||||||
|
banksync.SyncOpts{From: from, To: to, Sort: true})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sync: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
|
||||||
|
|
||||||
|
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||||
|
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("infer: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||||
|
|
||||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
|
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.26.1
|
|||||||
require (
|
require (
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/invopop/jsonschema v0.14.0
|
github.com/invopop/jsonschema v0.14.0
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
google.golang.org/api v0.278.0
|
google.golang.org/api v0.278.0
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP
|
|||||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ type ExceptionLoader interface {
|
|||||||
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheFlusher can invalidate all cached data.
|
||||||
|
type CacheFlusher interface {
|
||||||
|
FlushCache() (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Sources is the aggregate interface required by ReconcileReport.
|
// Sources is the aggregate interface required by ReconcileReport.
|
||||||
type Sources interface {
|
type Sources interface {
|
||||||
AttendanceLoader
|
AttendanceLoader
|
||||||
TransactionLoader
|
TransactionLoader
|
||||||
ExceptionLoader
|
ExceptionLoader
|
||||||
|
CacheFlusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
||||||
@@ -52,3 +58,5 @@ func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction,
|
|||||||
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
return nil, ErrIOPending
|
return nil, ErrIOPending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (stubSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
|
|||||||
return f.exceptions, nil
|
return f.exceptions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|
||||||
func TestReconcileReport(t *testing.T) {
|
func TestReconcileReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := fakeSources{
|
s := fakeSources{
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ const (
|
|||||||
|
|
||||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||||
// Source month → target month (source attendance accumulated into target).
|
// Source month → target month (source attendance accumulated into target).
|
||||||
var AdultMergedMonths = map[string]string{}
|
var AdultMergedMonths = map[string]string{
|
||||||
|
"2025-09": "2025-10",
|
||||||
|
}
|
||||||
|
|
||||||
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||||
var JuniorMergedMonths = map[string]string{
|
var JuniorMergedMonths = map[string]string{
|
||||||
@@ -109,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
|
|||||||
return parseTransactionRows(rows)
|
return parseTransactionRows(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushCache deletes all cached data files and resets in-memory cache state.
|
||||||
|
func (s *realSources) FlushCache() (int, error) { return s.cache.Flush() }
|
||||||
|
|
||||||
// LoadExceptions fetches the exceptions tab (cached).
|
// LoadExceptions fetches the exceptions tab (cached).
|
||||||
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// adultMergedMonths is empty so 2025-09 stays as-is
|
// AdultMergedMonths sends 2025-09 → 2025-10
|
||||||
if len(months) != 1 || months[0] != "2025-09" {
|
if len(months) != 1 || months[0] != "2025-10" {
|
||||||
t.Errorf("unexpected months: %v", months)
|
t.Errorf("unexpected months: %v", months)
|
||||||
}
|
}
|
||||||
if len(members) != 2 {
|
if len(members) != 2 {
|
||||||
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byName := map[string]int{}
|
byName := map[string]int{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
byName[m.Name] = m.Fees["2025-09"].Attendance
|
byName[m.Name] = m.Fees["2025-10"].Attendance
|
||||||
}
|
}
|
||||||
if byName["Alice"] != 2 {
|
if byName["Alice"] != 2 {
|
||||||
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
||||||
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byName := map[string]int{}
|
byName := map[string]int{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
byName[m.Name] = m.Fees["2025-09"].Expected
|
byName[m.Name] = m.Fees["2025-10"].Expected
|
||||||
}
|
}
|
||||||
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
|
// 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 750
|
||||||
if byName["Alice"] != 750 {
|
if byName["Alice"] != 750 {
|
||||||
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type Handler struct {
|
|||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushCache invalidates all cached data via the underlying Sources.
|
||||||
|
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
|
||||||
|
|
||||||
// ServeVersion handles GET /api/version.
|
// ServeVersion handles GET /api/version.
|
||||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, VersionResponse{
|
writeJSON(w, VersionResponse{
|
||||||
@@ -34,37 +37,64 @@ func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ServeAdults handles GET /api/adults.
|
// ServeAdults handles GET /api/adults.
|
||||||
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
resp, err := h.AssembleAdults(r.Context())
|
||||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeError(w, r, err)
|
h.writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssembleAdults loads all data and builds the adults view model.
|
||||||
|
// Shared between the JSON API route and the HTML handler.
|
||||||
|
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
|
||||||
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return AdultsResponse{}, err
|
||||||
|
}
|
||||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||||
writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeJuniors handles GET /api/juniors.
|
// ServeJuniors handles GET /api/juniors.
|
||||||
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
resp, err := h.AssembleJuniors(r.Context())
|
||||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeError(w, r, err)
|
h.writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssembleJuniors loads all data and builds the juniors view model.
|
||||||
|
// Shared between the JSON API route and the HTML handler.
|
||||||
|
func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) {
|
||||||
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
return JuniorsResponse{}, err
|
||||||
|
}
|
||||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||||
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServePayments handles GET /api/payments.
|
// ServePayments handles GET /api/payments.
|
||||||
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
resp, err := h.AssemblePayments(r.Context())
|
||||||
txns, err := h.Sources.LoadTransactions(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
|
h.writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssemblePayments loads transactions and builds the payments view model.
|
||||||
|
// Shared between the JSON API route and the HTML handler.
|
||||||
|
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
|
||||||
|
txns, err := h.Sources.LoadTransactions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err)
|
||||||
|
}
|
||||||
|
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
||||||
|
|||||||
@@ -1,34 +1,143 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
// HTMLHandler serves the Go-native HTML frontend.
|
// HTMLHandler serves the Go-native HTML frontend.
|
||||||
type HTMLHandler struct {
|
type HTMLHandler struct {
|
||||||
renderer *Renderer
|
renderer *Renderer
|
||||||
build BuildInfo
|
build BuildInfo
|
||||||
|
apiHandler *api.Handler
|
||||||
|
actions ActionHandlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLHandler constructs an HTMLHandler.
|
// NewHTMLHandler constructs an HTMLHandler.
|
||||||
func NewHTMLHandler(r *Renderer, b BuildInfo) *HTMLHandler {
|
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
|
||||||
return &HTMLHandler{renderer: r, build: b}
|
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "adults", PageData{Active: "adults", Build: h.build})
|
data, err := h.apiHandler.AssembleAdults(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
|
PageData: PageData{Active: "adults", Build: h.build},
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
|
PageData: PageData{Active: "adults", Build: h.build},
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "juniors", PageData{Active: "juniors", Build: h.build})
|
data, err := h.apiHandler.AssembleJuniors(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.renderer.Render(w, "juniors", JuniorsPageData{
|
||||||
|
PageData: PageData{Active: "juniors", Build: h.build},
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderer.Render(w, "juniors", JuniorsPageData{
|
||||||
|
PageData: PageData{Active: "juniors", Build: h.build},
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
|
data, err := h.apiHandler.AssemblePayments(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.renderer.Render(w, "payments", PaymentsPageData{
|
||||||
|
PageData: PageData{Active: "payments", Build: h.build},
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderer.Render(w, "payments", PaymentsPageData{
|
||||||
|
PageData: PageData{Active: "payments", Build: h.build},
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
||||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
|
pd := PageData{Active: "sync", Build: h.build}
|
||||||
|
|
||||||
|
if h.actions.BankSync == nil {
|
||||||
|
h.renderer.Render(w, "sync", SyncPageData{
|
||||||
|
PageData: pd,
|
||||||
|
Output: "Bank sync is not configured.",
|
||||||
|
Success: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
success := true
|
||||||
|
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
|
||||||
|
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
|
||||||
|
n, err := h.apiHandler.FlushCache()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
|
||||||
|
success = false
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderer.Render(w, "sync", SyncPageData{
|
||||||
|
PageData: pd,
|
||||||
|
Output: buf.String(),
|
||||||
|
Success: success,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
||||||
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||||
|
PageData: PageData{Active: "flush", Build: h.build},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
|
||||||
|
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, _ := h.apiHandler.FlushCache()
|
||||||
|
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||||
|
PageData: PageData{Active: "flush", Build: h.build},
|
||||||
|
Flushed: true,
|
||||||
|
Deleted: n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
|
||||||
|
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
account := q.Get("account")
|
||||||
|
amount := q.Get("amount")
|
||||||
|
message := q.Get("message")
|
||||||
|
if account == "" {
|
||||||
|
account = h.apiHandler.Config.BankAccount
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
amount = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
|
||||||
|
png, err := RenderQRCode(payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(png)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,66 @@
|
|||||||
package web_test
|
package web_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/domain/reconcile"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee
|
||||||
|
// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's
|
||||||
|
// test_adults_route fixture.
|
||||||
|
type fixtureSources struct{}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||||
|
return []reconcile.Member{
|
||||||
|
{Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{
|
||||||
|
"2026-01": {Expected: 750, Attendance: 4},
|
||||||
|
}},
|
||||||
|
}, []string{"2026-01"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
|
||||||
|
amt := float64(750)
|
||||||
|
return []reconcile.Transaction{
|
||||||
|
{Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|
||||||
|
func fixtureHandler(t *testing.T) *api.Handler {
|
||||||
|
t.Helper()
|
||||||
|
return &api.Handler{
|
||||||
|
Sources: fixtureSources{},
|
||||||
|
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHTMLHandlerSmoke(t *testing.T) {
|
func TestHTMLHandlerSmoke(t *testing.T) {
|
||||||
renderer, err := web.NewRenderer()
|
renderer, err := web.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
h := web.NewHTMLHandler(renderer, b)
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
@@ -25,7 +70,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
{"/juniors", h.ServeJuniors},
|
{"/juniors", h.ServeJuniors},
|
||||||
{"/payments", h.ServePayments},
|
{"/payments", h.ServePayments},
|
||||||
{"/sync-bank", h.ServeSync},
|
{"/sync-bank", h.ServeSync},
|
||||||
{"/flush-cache", h.ServeFlushCache},
|
{"/flush-cache", h.ServeFlushCacheGET},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -52,3 +97,287 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdultsPage(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeAdults(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"Adults Dashboard",
|
||||||
|
"Test Member",
|
||||||
|
"750/750 CZK (4)", // paid/expected (attendance)
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python assertion: cell text never says literally "OK"
|
||||||
|
if strings.Contains(body, ">OK<") {
|
||||||
|
t.Error("body should not contain >OK<")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModalMarkup(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
page string
|
||||||
|
wantRow string // data-name present only when the page has rows
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults, "adults", `data-name="Test Member"`},
|
||||||
|
{"/juniors", h.ServeJuniors, "juniors", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tc.handler(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-page="` + tc.page + `"`,
|
||||||
|
`id="memberModal"`,
|
||||||
|
`/static/js/member-detail.js`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.wantRow != "" && !strings.Contains(body, tc.wantRow) {
|
||||||
|
t.Errorf("body missing info-icon row %q", tc.wantRow)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentsPage(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServePayments(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"Payments Ledger",
|
||||||
|
"<h2>Test Member</h2>",
|
||||||
|
"750 CZK",
|
||||||
|
"2026-01-01",
|
||||||
|
"2026-01",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeQR(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeQR(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
|
||||||
|
t.Errorf("Content-Type = %q, want image/png", ct)
|
||||||
|
}
|
||||||
|
// PNG magic bytes: \x89PNG\r\n\x1a\n
|
||||||
|
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
|
||||||
|
body := w.Body.Bytes()
|
||||||
|
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
|
||||||
|
t.Error("response body does not start with PNG magic bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeFlushCacheGET(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeFlushCacheGET(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Flush Cache") {
|
||||||
|
t.Error("body missing Flush Cache heading")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "file(s) deleted") {
|
||||||
|
t.Error("GET should not show deleted count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeFlushCachePOST(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeFlushCachePOST(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
// fixtureSources.FlushCache returns 0
|
||||||
|
if !strings.Contains(body, "file(s) deleted") {
|
||||||
|
t.Error("POST should show deleted count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeSync(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
|
||||||
|
actions := web.ActionHandlers{
|
||||||
|
BankSync: func(_ context.Context, out io.Writer) error {
|
||||||
|
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||||
|
fmt.Fprintln(out, "Synced 3 new transaction(s).")
|
||||||
|
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||||
|
fmt.Fprintln(out, "Inferred 2 row(s).")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeSync(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Sync Bank Data",
|
||||||
|
"Synced 3 new transaction(s).",
|
||||||
|
"Inferred 2 row(s).",
|
||||||
|
"Flush Cache",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentQRMarkup(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults},
|
||||||
|
{"/juniors", h.ServeJuniors},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tc.handler(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="qrModal"`,
|
||||||
|
`id="qrImg"`,
|
||||||
|
`id="qrTitle"`,
|
||||||
|
`id="qrAccount"`,
|
||||||
|
`id="qrAmount"`,
|
||||||
|
`id="qrMessage"`,
|
||||||
|
`/static/js/payment-qr.js`,
|
||||||
|
`data-bank-account="CZ0000000000000000000000"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay buttons must use <button>, never a bare href to /qr.
|
||||||
|
if strings.Contains(body, `href="/qr`) {
|
||||||
|
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeVersion(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fixtureHandler(t).ServeVersion(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
|
||||||
|
t.Fatalf("decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"tag", "commit", "build_date"} {
|
||||||
|
if _, ok := raw[key]; !ok {
|
||||||
|
t.Errorf("JSON response missing key %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
50
go/internal/web/qr.go
Normal file
50
go/internal/web/qr.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
|
||||||
|
|
||||||
|
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
|
||||||
|
// Invalid account falls back to defaultAccount.
|
||||||
|
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
|
||||||
|
// Message is truncated to 60 runes and stripped of '*' characters.
|
||||||
|
func BuildSPD(account, amount, message, defaultAccount string) string {
|
||||||
|
if !validAccount.MatchString(account) {
|
||||||
|
account = defaultAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
var amtStr string
|
||||||
|
var f float64
|
||||||
|
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
|
||||||
|
amtStr = "0.00"
|
||||||
|
} else {
|
||||||
|
amtStr = fmt.Sprintf("%.2f", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
if utf8.RuneCountInString(message) > 60 {
|
||||||
|
runes := []rune(message)
|
||||||
|
message = string(runes[:60])
|
||||||
|
}
|
||||||
|
message = strings.ReplaceAll(message, "*", "")
|
||||||
|
|
||||||
|
var accStr string
|
||||||
|
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
|
||||||
|
accStr = parts[0] + "*BC:" + parts[1]
|
||||||
|
} else {
|
||||||
|
accStr = account
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
|
||||||
|
func RenderQRCode(payload string) ([]byte, error) {
|
||||||
|
return qrcode.Encode(payload, qrcode.Medium, 256)
|
||||||
|
}
|
||||||
91
go/internal/web/qr_test.go
Normal file
91
go/internal/web/qr_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQRBuildSPD(t *testing.T) {
|
||||||
|
const def = "2702008874/2010"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
account string
|
||||||
|
amount string
|
||||||
|
message string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "czech account",
|
||||||
|
account: "2702008874/2010",
|
||||||
|
amount: "700",
|
||||||
|
message: "Test Member: 01/2026",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:700.00*CC:CZK*MSG:Test Member: 01/2026",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IBAN account",
|
||||||
|
account: "CZ6508000000192000145399",
|
||||||
|
amount: "500",
|
||||||
|
message: "hi",
|
||||||
|
want: "SPD*1.0*ACC:CZ6508000000192000145399*AM:500.00*CC:CZK*MSG:hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid account falls back to default",
|
||||||
|
account: "NOTANACCOUNT",
|
||||||
|
amount: "100",
|
||||||
|
message: "x",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty account falls back to default",
|
||||||
|
account: "",
|
||||||
|
amount: "0",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative amount clamped to 0.00",
|
||||||
|
account: def,
|
||||||
|
amount: "-1",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "amount over 10M clamped to 0.00",
|
||||||
|
account: def,
|
||||||
|
amount: "99999999",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric amount becomes 0.00",
|
||||||
|
account: def,
|
||||||
|
amount: "abc",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "asterisks stripped from message",
|
||||||
|
account: def,
|
||||||
|
amount: "100",
|
||||||
|
message: "pay*now",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:paynow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message truncated to 60 runes",
|
||||||
|
account: def,
|
||||||
|
amount: "0",
|
||||||
|
message: strings.Repeat("á", 65),
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:" + strings.Repeat("á", 60),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := BuildSPD(tc.account, tc.amount, tc.message, def)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -13,6 +14,41 @@ type PageData struct {
|
|||||||
Build BuildInfo
|
Build BuildInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdultsPageData is the view model for the /adults HTML page.
|
||||||
|
type AdultsPageData struct {
|
||||||
|
PageData
|
||||||
|
Data api.AdultsResponse
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JuniorsPageData is the view model for the /juniors HTML page.
|
||||||
|
type JuniorsPageData struct {
|
||||||
|
PageData
|
||||||
|
Data api.JuniorsResponse
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentsPageData is the view model for the /payments HTML page.
|
||||||
|
type PaymentsPageData struct {
|
||||||
|
PageData
|
||||||
|
Data api.PaymentsResponse
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPageData is the view model for the /sync-bank HTML page.
|
||||||
|
type SyncPageData struct {
|
||||||
|
PageData
|
||||||
|
Output string
|
||||||
|
Success bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushPageData is the view model for the /flush-cache HTML page.
|
||||||
|
type FlushPageData struct {
|
||||||
|
PageData
|
||||||
|
Flushed bool
|
||||||
|
Deleted int
|
||||||
|
}
|
||||||
|
|
||||||
// Renderer parses and executes HTML templates from the embedded FS.
|
// Renderer parses and executes HTML templates from the embedded FS.
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
tmpls map[string]*template.Template
|
tmpls map[string]*template.Template
|
||||||
@@ -20,12 +56,14 @@ type Renderer struct {
|
|||||||
|
|
||||||
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||||
|
|
||||||
|
var tmplFuncs = template.FuncMap{}
|
||||||
|
|
||||||
// NewRenderer parses all templates from the embedded FS.
|
// NewRenderer parses all templates from the embedded FS.
|
||||||
// A parse failure should be treated as a startup-time fatal error.
|
// A parse failure should be treated as a startup-time fatal error.
|
||||||
func NewRenderer() (*Renderer, error) {
|
func NewRenderer() (*Renderer, error) {
|
||||||
tmpls := make(map[string]*template.Template, len(pageNames))
|
tmpls := make(map[string]*template.Template, len(pageNames))
|
||||||
for _, name := range pageNames {
|
for _, name := range pageNames {
|
||||||
t, err := template.New("").ParseFS(templateFS,
|
t, err := template.New("").Funcs(tmplFuncs).ParseFS(templateFS,
|
||||||
"templates/base.tmpl",
|
"templates/base.tmpl",
|
||||||
"templates/partials/nav.tmpl",
|
"templates/partials/nav.tmpl",
|
||||||
"templates/partials/footer.tmpl",
|
"templates/partials/footer.tmpl",
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
"fuj-management/go/internal/web/middleware"
|
"fuj-management/go/internal/web/middleware"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,8 +20,16 @@ type BuildInfo struct {
|
|||||||
BuildDate string
|
BuildDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionHandlers holds function closures for side-effectful operations that
|
||||||
|
// require dependencies (fio, sheets) not present on the core API handler.
|
||||||
|
type ActionHandlers struct {
|
||||||
|
// BankSync runs sync+infer and writes a human-readable log to out.
|
||||||
|
// nil disables the /sync-bank action (renders an error instead).
|
||||||
|
BankSync func(ctx context.Context, out io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
// Run registers routes and starts the HTTP server on addr.
|
// Run registers routes and starts the HTTP server on addr.
|
||||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config, actions ActionHandlers) error {
|
||||||
renderer, err := NewRenderer()
|
renderer, err := NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init templates: %w", err)
|
return fmt.Errorf("init templates: %w", err)
|
||||||
@@ -33,7 +43,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
hh := NewHTMLHandler(renderer, build)
|
hh := NewHTMLHandler(renderer, build, ah, actions)
|
||||||
|
|
||||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,13 +60,16 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
|||||||
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
||||||
mux.HandleFunc("GET /payments", hh.ServePayments)
|
mux.HandleFunc("GET /payments", hh.ServePayments)
|
||||||
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
||||||
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
|
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCacheGET)
|
||||||
|
mux.HandleFunc("POST /flush-cache", hh.ServeFlushCachePOST)
|
||||||
|
mux.HandleFunc("GET /qr", hh.ServeQR)
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||||
|
|
||||||
// JSON API routes
|
// JSON API routes
|
||||||
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||||
|
mux.HandleFunc("GET /version", ah.ServeVersion)
|
||||||
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||||
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||||
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ tr:hover {
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
background: #ff3333;
|
background: #ff3333;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -441,6 +442,23 @@ tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* QR Modal styles */
|
/* QR Modal styles */
|
||||||
|
#qrModal {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#qrModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
#qrModal .modal-content {
|
#qrModal .modal-content {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -475,3 +493,59 @@ tr:hover {
|
|||||||
color: #00ff00;
|
color: #00ff00;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* /payments ledger */
|
||||||
|
.ledger-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-block {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table th,
|
||||||
|
.txn-table td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table th {
|
||||||
|
color: #555;
|
||||||
|
text-transform: lowercase;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-date {
|
||||||
|
min-width: 80px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-amount {
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right !important;
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-purpose {
|
||||||
|
min-width: 100px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-message {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|||||||
91
go/internal/web/static/js/filters.js
Normal file
91
go/internal/web/static/js/filters.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Client-side filters for the Adults (and future Juniors) dashboard table.
|
||||||
|
// Mirrors adults.html:864-1051 from the Python frontend.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// NFD-normalize + strip diacritics + lowercase, matching Python's
|
||||||
|
// unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode().lower()
|
||||||
|
function normalize(s) {
|
||||||
|
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const currentMonth = container.dataset.currentMonth || '';
|
||||||
|
|
||||||
|
const nameInput = document.getElementById('nameFilter');
|
||||||
|
const fromSelect = document.getElementById('fromMonth');
|
||||||
|
const toSelect = document.getElementById('toMonth');
|
||||||
|
const applyBtn = document.getElementById('applyFilter');
|
||||||
|
const clearBtn = document.getElementById('clearFilter');
|
||||||
|
|
||||||
|
// ── Month column visibility ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Hide columns whose raw month is in the future by default.
|
||||||
|
function hideFutureMonths() {
|
||||||
|
if (!currentMonth) return;
|
||||||
|
document.querySelectorAll('[data-raw-month]').forEach(el => {
|
||||||
|
if (el.dataset.rawMonth > currentMonth) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Sync toMonth select to the last non-hidden month.
|
||||||
|
const ths = [...document.querySelectorAll('thead th[data-month-idx]')];
|
||||||
|
const visibleIdxs = ths
|
||||||
|
.filter(th => !th.classList.contains('month-hidden'))
|
||||||
|
.map(th => parseInt(th.dataset.monthIdx, 10));
|
||||||
|
if (visibleIdxs.length) {
|
||||||
|
toSelect.value = String(visibleIdxs[visibleIdxs.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMonthFilter() {
|
||||||
|
const from = fromSelect.value !== '' ? parseInt(fromSelect.value, 10) : -Infinity;
|
||||||
|
const to = toSelect.value !== '' ? parseInt(toSelect.value, 10) : Infinity;
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(el => {
|
||||||
|
const idx = parseInt(el.dataset.monthIdx, 10);
|
||||||
|
if (idx < from || idx > to) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMonthFilter() {
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(el => {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
});
|
||||||
|
fromSelect.value = '';
|
||||||
|
toSelect.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name row visibility ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function applyNameFilter() {
|
||||||
|
const query = normalize(nameInput.value.trim());
|
||||||
|
document.querySelectorAll('tr.member-row').forEach(row => {
|
||||||
|
const name = normalize(row.dataset.name || '');
|
||||||
|
row.style.display = (!query || name.includes(query)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event wiring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
nameInput.addEventListener('input', applyNameFilter);
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', applyMonthFilter);
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
nameInput.value = '';
|
||||||
|
applyNameFilter();
|
||||||
|
clearMonthFilter();
|
||||||
|
hideFutureMonths();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initialise ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
hideFutureMonths();
|
||||||
|
}());
|
||||||
245
go/internal/web/static/js/member-detail.js
Normal file
245
go/internal/web/static/js/member-detail.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// Member-detail modal for /adults and /juniors pages.
|
||||||
|
// Fetches /api/<page> once on load, caches the response, renders on row click.
|
||||||
|
// Mirrors templates/adults.html JS (lines 718-993) but as a standalone module.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const page = container.dataset.page; // "adults" | "juniors"
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
let apiData = null;
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
// ── Data load ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (apiData) return apiData;
|
||||||
|
const r = await fetch('/api/' + page);
|
||||||
|
if (!r.ok) throw new Error('[member-detail] failed to fetch /api/' + page + ': ' + r.status);
|
||||||
|
apiData = await r.json();
|
||||||
|
return apiData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-warm on page load so first click is instant.
|
||||||
|
loadData().catch(function (err) { console.error(err); });
|
||||||
|
|
||||||
|
// ── Modal render ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function showMember(name) {
|
||||||
|
currentMemberName = name;
|
||||||
|
const data = (await loadData());
|
||||||
|
const member = data.member_data[name];
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
document.getElementById('modalMemberName').textContent = name;
|
||||||
|
document.getElementById('modalTier').textContent = 'Tier: ' + (member.tier || '-');
|
||||||
|
|
||||||
|
const monthLabels = data.month_labels || {};
|
||||||
|
const statusBody = document.getElementById('modalStatusBody');
|
||||||
|
statusBody.innerHTML = '';
|
||||||
|
|
||||||
|
const allTransactions = [];
|
||||||
|
const monthKeys = Object.keys(member.months || {}).sort().reverse();
|
||||||
|
|
||||||
|
monthKeys.forEach(function (m) {
|
||||||
|
const md = member.months[m];
|
||||||
|
const expected = md.expected; // int for adults; int or "?" for juniors
|
||||||
|
const paid = md.paid || 0;
|
||||||
|
const attendance = md.attendance_count || 0;
|
||||||
|
const originalExpected = md.original_expected;
|
||||||
|
const isUnknown = (expected === '?');
|
||||||
|
|
||||||
|
let status = '-';
|
||||||
|
let statusClass = '';
|
||||||
|
if (!isUnknown && (expected > 0 || paid > 0)) {
|
||||||
|
if (paid >= expected && expected > 0) {
|
||||||
|
status = paid + '/' + expected;
|
||||||
|
statusClass = 'cell-ok';
|
||||||
|
} else if (paid > 0) {
|
||||||
|
status = paid + '/' + expected;
|
||||||
|
} else {
|
||||||
|
status = '0/' + expected;
|
||||||
|
statusClass = 'cell-unpaid';
|
||||||
|
}
|
||||||
|
} else if (isUnknown) {
|
||||||
|
status = paid > 0 ? paid + '/?' : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectedCell;
|
||||||
|
if (isUnknown) {
|
||||||
|
expectedCell = '?';
|
||||||
|
} else if (md.exception) {
|
||||||
|
expectedCell = '<span style="color: #ffaa00;" title="Overridden from ' + originalExpected + '">' + expected + '*</span>';
|
||||||
|
} else {
|
||||||
|
expectedCell = expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMonth = monthLabels[m] || m;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML =
|
||||||
|
'<td style="color: #888;">' + displayMonth + '</td>' +
|
||||||
|
'<td style="text-align: center; color: #ccc;">' + attendance + '</td>' +
|
||||||
|
'<td style="text-align: center; color: #ccc;">' + expectedCell + '</td>' +
|
||||||
|
'<td style="text-align: center; color: #ccc;">' + paid + '</td>' +
|
||||||
|
'<td style="text-align: right;" class="' + statusClass + '">' + status + '</td>';
|
||||||
|
statusBody.appendChild(row);
|
||||||
|
|
||||||
|
if (md.transactions) {
|
||||||
|
md.transactions.forEach(function (tx) {
|
||||||
|
allTransactions.push(Object.assign({ month: m }, tx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exceptions
|
||||||
|
const exList = document.getElementById('modalExceptionList');
|
||||||
|
const exSection = document.getElementById('modalExceptionSection');
|
||||||
|
exList.innerHTML = '';
|
||||||
|
const exceptions = [];
|
||||||
|
monthKeys.forEach(function (m) {
|
||||||
|
if (member.months[m].exception) {
|
||||||
|
exceptions.push(Object.assign({ month: m }, member.months[m].exception));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (exceptions.length > 0) {
|
||||||
|
exSection.style.display = 'block';
|
||||||
|
exceptions.forEach(function (ex) {
|
||||||
|
const displayMonth = monthLabels[ex.month] || ex.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + displayMonth + '</div>' +
|
||||||
|
'<div class="tx-main"><span class="tx-amount" style="color: #ffaa00;">' + ex.amount + ' CZK</span></div>' +
|
||||||
|
'<div class="tx-msg">' + (ex.note || 'No details provided.') + '</div>';
|
||||||
|
exList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
exSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other transactions
|
||||||
|
const otherList = document.getElementById('modalOtherList');
|
||||||
|
const otherSection = document.getElementById('modalOtherSection');
|
||||||
|
otherList.innerHTML = '';
|
||||||
|
if (member.other_transactions && member.other_transactions.length > 0) {
|
||||||
|
otherSection.style.display = 'block';
|
||||||
|
member.other_transactions.forEach(function (tx) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + tx.date + ' | ' + (tx.purpose || 'Other') + '</div>' +
|
||||||
|
'<div class="tx-main">' +
|
||||||
|
'<span class="tx-amount" style="color: #66ccff;">' + tx.amount + ' CZK</span>' +
|
||||||
|
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tx-msg">' + (tx.message || '') + '</div>';
|
||||||
|
otherList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
otherSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched transactions (payment history)
|
||||||
|
const txList = document.getElementById('modalTxList');
|
||||||
|
txList.innerHTML = '';
|
||||||
|
if (allTransactions.length === 0) {
|
||||||
|
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
|
||||||
|
} else {
|
||||||
|
allTransactions.sort(function (a, b) { return b.date.localeCompare(a.date); });
|
||||||
|
allTransactions.forEach(function (tx) {
|
||||||
|
const displayMonth = monthLabels[tx.month] || tx.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + tx.date + ' | matched to ' + displayMonth + '</div>' +
|
||||||
|
'<div class="tx-main">' +
|
||||||
|
'<span class="tx-amount">' + tx.amount + ' CZK</span>' +
|
||||||
|
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tx-msg">' + (tx.message || '') + '</div>';
|
||||||
|
txList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw payments — hidden by default; reset toggle on each open
|
||||||
|
const rawList = document.getElementById('modalRawList');
|
||||||
|
const rawToggle = document.getElementById('rawPaymentsToggle');
|
||||||
|
rawList.style.display = 'none';
|
||||||
|
rawToggle.textContent = '[show]';
|
||||||
|
rawList.innerHTML = '';
|
||||||
|
const rawRows = (data.raw_payments || {})[name] || [];
|
||||||
|
if (rawRows.length === 0) {
|
||||||
|
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
|
||||||
|
} else {
|
||||||
|
rawRows.forEach(function (tx) {
|
||||||
|
const inferredNote = (tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount !== tx.amount)
|
||||||
|
? ' <span style="color:#888;">(inferred: ' + tx.inferred_amount + ')</span>'
|
||||||
|
: '';
|
||||||
|
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
|
||||||
|
const bankIdNote = tx.bank_id ? '<span style="color:#444;"> · bank_id: ' + tx.bank_id + '</span>' : '';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + tx.date + ' | purpose: ' + (tx.purpose || '—') + manualNote + '</div>' +
|
||||||
|
'<div class="tx-main">' +
|
||||||
|
'<span class="tx-amount">' + tx.amount + ' CZK' + inferredNote + '</span>' +
|
||||||
|
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tx-msg">' + (tx.message || '') + '</div>' +
|
||||||
|
'<div class="tx-meta">' + (tx.person || '') + bankIdNote + '</div>';
|
||||||
|
rawList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('memberModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raw-payments toggle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleRawPayments(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const list = document.getElementById('modalRawList');
|
||||||
|
const link = document.getElementById('rawPaymentsToggle');
|
||||||
|
const hidden = list.style.display === 'none';
|
||||||
|
list.style.display = hidden ? 'block' : 'none';
|
||||||
|
link.textContent = hidden ? '[hide]' : '[show]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close + keyboard nav ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('memberModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateMember(direction) {
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr.member-row'));
|
||||||
|
const visible = rows.filter(function (r) { return r.style.display !== 'none'; });
|
||||||
|
const idx = visible.findIndex(function (r) { return r.dataset.name === currentMemberName; });
|
||||||
|
if (idx === -1) return;
|
||||||
|
const next = idx + direction;
|
||||||
|
if (next >= 0 && next < visible.length) {
|
||||||
|
showMember(visible[next].dataset.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.querySelectorAll('.info-icon[data-name]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function (ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
showMember(el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closeModal(); return; }
|
||||||
|
const modal = document.getElementById('memberModal');
|
||||||
|
if (!modal.classList.contains('active')) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
|
||||||
|
});
|
||||||
|
}());
|
||||||
60
go/internal/web/static/js/payment-qr.js
Normal file
60
go/internal/web/static/js/payment-qr.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const bankAccount = container.dataset.bankAccount || '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('qrModal');
|
||||||
|
const titleEl = document.getElementById('qrTitle');
|
||||||
|
const imgEl = document.getElementById('qrImg');
|
||||||
|
const accountEl = document.getElementById('qrAccount');
|
||||||
|
const amountEl = document.getElementById('qrAmount');
|
||||||
|
const messageEl = document.getElementById('qrMessage');
|
||||||
|
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||||
|
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges converted piece-wise.
|
||||||
|
// Mirrors templates/adults.html showPayQR logic.
|
||||||
|
function toCzechMonth(rawMonth) {
|
||||||
|
return rawMonth.split('+')
|
||||||
|
.map(function (p) { return p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'); })
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQR(name, amount, month, rawMonth) {
|
||||||
|
var numericMonth = toCzechMonth(rawMonth);
|
||||||
|
var message = name + ': ' + numericMonth;
|
||||||
|
|
||||||
|
titleEl.innerText = 'Payment for ' + month;
|
||||||
|
accountEl.innerText = bankAccount;
|
||||||
|
amountEl.innerText = amount;
|
||||||
|
messageEl.innerText = message;
|
||||||
|
|
||||||
|
imgEl.src = '/qr?'
|
||||||
|
+ 'account=' + encodeURIComponent(bankAccount)
|
||||||
|
+ '&amount=' + encodeURIComponent(amount)
|
||||||
|
+ '&message=' + encodeURIComponent(message);
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQR() {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('.pay-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
showQR(
|
||||||
|
btn.dataset.name,
|
||||||
|
btn.dataset.amount,
|
||||||
|
btn.dataset.month,
|
||||||
|
btn.dataset.rawMonth
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -1,5 +1,207 @@
|
|||||||
{{define "title"}}Adults{{end}}
|
{{define "title"}}Adults{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Adults Dashboard</h1>
|
<h1>Adults Dashboard</h1>
|
||||||
<p class="description">Coming in M6.2</p>
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="description">Error loading data: {{.Error}}</div>
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
|
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
|
||||||
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="fromMonth">From</label>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<option value="{{$i}}">{{$m}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="toMonth">To</label>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<option value="{{$i}}">{{$m}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
|
||||||
|
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Data.Results}}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
|
||||||
|
{{end}}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $row := .Data.Results}}
|
||||||
|
<tr class="member-row" data-name="{{$row.Name}}">
|
||||||
|
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||||
|
{{range $i, $cell := $row.Months}}
|
||||||
|
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
||||||
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
|
{{$cell.Text}}
|
||||||
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
|
{{$row.Balance}}
|
||||||
|
{{if gt $row.PayableAmount 0}}
|
||||||
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
|
||||||
|
{{range $i, $t := .Data.Totals}}
|
||||||
|
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{$t.Text}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="description">No members found.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Credits}}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{{range .Data.Credits}}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{.Name}}</span>
|
||||||
|
<span class="list-item-val">{{.Amount}} CZK</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Debts}}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{{range .Data.Debts}}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{.Name}}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Unmatched}}
|
||||||
|
<h2>Unmatched Transactions</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
<div class="unmatched-row unmatched-header">
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
<span>Sender</span>
|
||||||
|
<span>Message</span>
|
||||||
|
</div>
|
||||||
|
{{range .Data.Unmatched}}
|
||||||
|
<div class="unmatched-row">
|
||||||
|
<span>{{.Date}}</span>
|
||||||
|
<span>{{printf "%.0f" .Amount}}</span>
|
||||||
|
<span>{{.Sender}}</span>
|
||||||
|
<span>{{.Message}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div id="memberModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('memberModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="modalStatusBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">
|
||||||
|
Raw Payments
|
||||||
|
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||||
|
</div>
|
||||||
|
<div id="modalRawList" class="tx-list" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
<script src="/static/js/payment-qr.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{{define "title"}}Flush Cache{{end}}
|
{{define "title"}}Flush Cache{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Flush Cache</h1>
|
<h1>Flush Cache</h1>
|
||||||
<p class="description">Coming in M6.6</p>
|
{{if .Flushed}}
|
||||||
|
<p class="status-ok">Cache flushed: {{.Deleted}} file(s) deleted.</p>
|
||||||
|
{{end}}
|
||||||
|
<p class="description">Deletes all cached data files so the next request fetches fresh data from Google Sheets.</p>
|
||||||
|
<form method="POST" action="/flush-cache">
|
||||||
|
<button type="submit" class="btn">Flush Cache</button>
|
||||||
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,187 @@
|
|||||||
{{define "title"}}Juniors{{end}}
|
{{define "title"}}Juniors{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Juniors Dashboard</h1>
|
<h1>Juniors Dashboard</h1>
|
||||||
<p class="description">Coming in M6.3</p>
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="description">Error loading data: {{.Error}}</div>
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
|
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
|
||||||
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="fromMonth">From</label>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<option value="{{$i}}">{{$m}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="toMonth">To</label>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<option value="{{$i}}">{{$m}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
|
||||||
|
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Data.Results}}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
|
||||||
|
{{end}}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $row := .Data.Results}}
|
||||||
|
<tr class="member-row" data-name="{{$row.Name}}">
|
||||||
|
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||||
|
{{range $i, $cell := $row.Months}}
|
||||||
|
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
||||||
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
|
{{$cell.Text}}
|
||||||
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
|
{{$row.Balance}}
|
||||||
|
{{if gt $row.PayableAmount 0}}
|
||||||
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
|
||||||
|
{{range $i, $t := .Data.Totals}}
|
||||||
|
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{$t.Text}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="description">No members found.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Credits}}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{{range .Data.Credits}}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{.Name}}</span>
|
||||||
|
<span class="list-item-val">{{.Amount}} CZK</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Debts}}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{{range .Data.Debts}}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{.Name}}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div id="memberModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('memberModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="modalStatusBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">
|
||||||
|
Raw Payments
|
||||||
|
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||||
|
</div>
|
||||||
|
<div id="modalRawList" class="tx-list" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
<script src="/static/js/payment-qr.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
{{define "title"}}Payments Ledger{{end}}
|
{{define "title"}}Payments Ledger{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Payments Ledger</h1>
|
<h1>Payments Ledger</h1>
|
||||||
<p class="description">Coming in M6.4</p>
|
<p class="description">
|
||||||
|
All bank transactions from the
|
||||||
|
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
|
||||||
|
grouped by member. Names are matched against the
|
||||||
|
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
|
||||||
|
</p>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<div class="ledger-container">
|
||||||
|
{{range $person := .Data.SortedPeople}}
|
||||||
|
<div class="member-block">
|
||||||
|
<h2>{{$person}}</h2>
|
||||||
|
<table class="txn-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="txn-date">Date</th>
|
||||||
|
<th class="txn-amount">Amount</th>
|
||||||
|
<th class="txn-purpose">Purpose</th>
|
||||||
|
<th class="txn-message">Bank Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $tx := index $.Data.GroupedPayments $person}}
|
||||||
|
<tr>
|
||||||
|
<td class="txn-date">{{$tx.Date}}</td>
|
||||||
|
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
|
||||||
|
<td class="txn-purpose">{{$tx.Purpose}}</td>
|
||||||
|
<td class="txn-message">{{$tx.Message}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
{{define "title"}}Sync Bank Data{{end}}
|
{{define "title"}}Sync Bank Data{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Sync Bank Data</h1>
|
<h1>Sync Bank Data</h1>
|
||||||
<p class="description">Coming in M6.6</p>
|
{{if .Output}}
|
||||||
|
{{if .Success}}
|
||||||
|
<p class="status-ok">Sync completed successfully.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="status-error">Sync failed — see log below.</p>
|
||||||
|
{{end}}
|
||||||
|
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
|
||||||
|
{{else}}
|
||||||
|
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
|
||||||
|
{{end}}
|
||||||
|
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ JUNIOR_MONTHLY_RATE = {
|
|||||||
"2026-03": 250 # reduced fee for March 2026
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
}
|
}
|
||||||
ADULT_MERGED_MONTHS = {
|
ADULT_MERGED_MONTHS = {
|
||||||
#"2025-12": "2026-01", # keys are merged into values
|
"2025-09": "2025-10", # keys are merged into values
|
||||||
#"2025-09": "2025-10"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
JUNIOR_MERGED_MONTHS = {
|
JUNIOR_MERGED_MONTHS = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
@@ -89,9 +90,11 @@ def parse_czech_amount(s: str) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_czech_date(s: str) -> str | None:
|
def parse_czech_date(s: str) -> str | None:
|
||||||
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
|
"""Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years
|
||||||
|
with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY'
|
||||||
|
and 'DD.MM.YY' in the same response."""
|
||||||
s = s.strip()
|
s = s.strip()
|
||||||
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
|
for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"):
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -146,6 +149,7 @@ def fetch_transactions_transparent(
|
|||||||
"bank_id": "", # HTML scraping doesn't give stable ID
|
"bank_id": "", # HTML scraping doesn't give stable ID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr)
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +173,8 @@ def fetch_transactions_api(
|
|||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||||
for tx in (tx_list.get("transaction") or []):
|
raw_list = tx_list.get("transaction") or []
|
||||||
|
for tx in raw_list:
|
||||||
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||||
def val(col_id):
|
def val(col_id):
|
||||||
col = tx.get(f"column{col_id}")
|
col = tx.get(f"column{col_id}")
|
||||||
@@ -197,6 +202,7 @@ def fetch_transactions_api(
|
|||||||
"currency": str(val(14) or "CZK"), # column14 = Currency
|
"currency": str(val(14) or "CZK"), # column14 = Currency
|
||||||
})
|
})
|
||||||
|
|
||||||
|
print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr)
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
@@ -204,8 +210,14 @@ def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
|
|||||||
"""Fetch transactions, using API if token available, else transparent page."""
|
"""Fetch transactions, using API if token available, else transparent page."""
|
||||||
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||||
if token:
|
if token:
|
||||||
|
print(f"fio: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
|
||||||
return fetch_transactions_api(token, date_from, date_to)
|
return fetch_transactions_api(token, date_from, date_to)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"fio: using transparent page (FIO_API_TOKEN unset — expect publishing lag), "
|
||||||
|
f"window {date_from}..{date_to}, account=2800359168",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
||||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||||
|
|||||||
@@ -77,6 +77,35 @@ def generate_sync_id(tx: dict) -> str:
|
|||||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _trunc(s: str, n: int = 40) -> str:
|
||||||
|
s = str(s)
|
||||||
|
return s if len(s) <= n else s[: n - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_fio_table(transactions: list[dict], statuses: list[str]) -> None:
|
||||||
|
headers = ["DATE", "AMOUNT", "SENDER", "VS", "MESSAGE", "BANKID", "STATUS"]
|
||||||
|
rows = [
|
||||||
|
[
|
||||||
|
str(tx.get("date", "")),
|
||||||
|
f"{float(tx.get('amount', 0)):.2f}",
|
||||||
|
str(tx.get("sender", "")),
|
||||||
|
str(tx.get("vs", "")),
|
||||||
|
_trunc(str(tx.get("message", ""))),
|
||||||
|
str(tx.get("bank_id", "")),
|
||||||
|
status,
|
||||||
|
]
|
||||||
|
for tx, status in zip(transactions, statuses)
|
||||||
|
]
|
||||||
|
widths = [
|
||||||
|
max(len(headers[i]), max((len(r[i]) for r in rows), default=0))
|
||||||
|
for i in range(len(headers))
|
||||||
|
]
|
||||||
|
sep = " "
|
||||||
|
print(sep.join(h.ljust(w) for h, w in zip(headers, widths)))
|
||||||
|
for row in rows:
|
||||||
|
print(sep.join(cell.ljust(w) for cell, w in zip(row, widths)))
|
||||||
|
|
||||||
|
|
||||||
def sort_sheet_by_date(service, spreadsheet_id):
|
def sort_sheet_by_date(service, spreadsheet_id):
|
||||||
"""Sort the sheet by the Date column (Column B)."""
|
"""Sort the sheet by the Date column (Column B)."""
|
||||||
# Get the sheet ID (gid) of the first sheet
|
# Get the sheet ID (gid) of the first sheet
|
||||||
@@ -104,12 +133,21 @@ def sort_sheet_by_date(service, spreadsheet_id):
|
|||||||
print("Sheet sorted by date.")
|
print("Sheet sorted by date.")
|
||||||
|
|
||||||
|
|
||||||
def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None, date_from_str: str = None, date_to_str: str = None, sort_by_date: bool = False):
|
def sync_to_sheets(
|
||||||
|
spreadsheet_id: str,
|
||||||
|
credentials_path: str,
|
||||||
|
days: int = None,
|
||||||
|
date_from_str: str = None,
|
||||||
|
date_to_str: str = None,
|
||||||
|
sort_by_date: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
|
print_fio_table: bool = False,
|
||||||
|
):
|
||||||
print(f"Connecting to Google Sheets using {credentials_path}...")
|
print(f"Connecting to Google Sheets using {credentials_path}...")
|
||||||
service = get_sheets_service(credentials_path)
|
service = get_sheets_service(credentials_path)
|
||||||
sheet = service.spreadsheets()
|
sheet = service.spreadsheets()
|
||||||
|
|
||||||
# 1. Fetch existing IDs from Column G (last column in A-G range)
|
# 1. Read existing sync IDs from Column K
|
||||||
print(f"Reading existing sync IDs from sheet...")
|
print(f"Reading existing sync IDs from sheet...")
|
||||||
try:
|
try:
|
||||||
result = sheet.values().get(
|
result = sheet.values().get(
|
||||||
@@ -117,19 +155,22 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
range="A1:K" # Include header and all columns to check Sync ID
|
range="A1:K" # Include header and all columns to check Sync ID
|
||||||
).execute()
|
).execute()
|
||||||
values = result.get("values", [])
|
values = result.get("values", [])
|
||||||
|
|
||||||
# Check and insert labels if missing
|
# Check and insert labels if missing
|
||||||
if not values or values[0] != COLUMN_LABELS:
|
if not values or values[0] != COLUMN_LABELS:
|
||||||
print("Inserting column labels...")
|
if dry_run:
|
||||||
sheet.values().update(
|
print("Dry run: would write header row")
|
||||||
spreadsheetId=spreadsheet_id,
|
else:
|
||||||
range="A1",
|
print("Inserting column labels...")
|
||||||
valueInputOption="USER_ENTERED",
|
sheet.values().update(
|
||||||
body={"values": [COLUMN_LABELS]}
|
spreadsheetId=spreadsheet_id,
|
||||||
).execute()
|
range="A1",
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body={"values": [COLUMN_LABELS]}
|
||||||
|
).execute()
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
else:
|
else:
|
||||||
# Sync ID is now the last column (index 10)
|
# Sync ID is the last column (index 10)
|
||||||
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading sheet (maybe empty?): {e}")
|
print(f"Error reading sheet (maybe empty?): {e}")
|
||||||
@@ -150,8 +191,12 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
transactions = fetch_transactions(df_str, dt_str)
|
transactions = fetch_transactions(df_str, dt_str)
|
||||||
print(f"Found {len(transactions)} transactions.")
|
print(f"Found {len(transactions)} transactions.")
|
||||||
|
|
||||||
# 3. Filter for new transactions
|
if dry_run:
|
||||||
|
print(f"Dry run: window {df_str} to {dt_str}, fetched {len(transactions)} transaction(s) from Fio")
|
||||||
|
|
||||||
|
# 3. Determine NEW/DUP for each transaction
|
||||||
new_rows = []
|
new_rows = []
|
||||||
|
tx_statuses = []
|
||||||
for tx in transactions:
|
for tx in transactions:
|
||||||
sync_id = generate_sync_id(tx)
|
sync_id = generate_sync_id(tx)
|
||||||
if sync_id not in existing_ids:
|
if sync_id not in existing_ids:
|
||||||
@@ -169,24 +214,48 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
tx.get("bank_id", ""),
|
tx.get("bank_id", ""),
|
||||||
sync_id,
|
sync_id,
|
||||||
])
|
])
|
||||||
|
tx_statuses.append("NEW")
|
||||||
|
else:
|
||||||
|
tx_statuses.append("DUP")
|
||||||
|
|
||||||
|
# 4. Print table (before early-return so all transactions are shown including DUPs)
|
||||||
|
if print_fio_table and transactions:
|
||||||
|
_print_fio_table(transactions, tx_statuses)
|
||||||
|
|
||||||
if not new_rows:
|
if not new_rows:
|
||||||
print("No new transactions to sync.")
|
if dry_run:
|
||||||
|
print("Dry run: would sync 0 new transaction(s).")
|
||||||
|
else:
|
||||||
|
print("No new transactions to sync.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 4. Append to sheet
|
# 5. Append to sheet or print dry-run would-write lines
|
||||||
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
if dry_run:
|
||||||
body = {"values": new_rows}
|
for tx, status in zip(transactions, tx_statuses):
|
||||||
sheet.values().append(
|
if status == "NEW":
|
||||||
spreadsheetId=spreadsheet_id,
|
print(
|
||||||
range="A2", # Appends to the end of the sheet
|
f"Dry run: would append"
|
||||||
valueInputOption="USER_ENTERED",
|
f" date={tx.get('date', '')}"
|
||||||
body=body
|
f" amount={tx.get('amount', '')}"
|
||||||
).execute()
|
f" sender={tx.get('sender', '')}"
|
||||||
print("Sync completed successfully.")
|
f" vs={tx.get('vs', '')}"
|
||||||
|
f" message={tx.get('message', '')}"
|
||||||
if sort_by_date:
|
)
|
||||||
sort_sheet_by_date(service, spreadsheet_id)
|
if sort_by_date:
|
||||||
|
print("Dry run: would sort by date")
|
||||||
|
print(f"Dry run: would sync {len(new_rows)} new transaction(s).")
|
||||||
|
else:
|
||||||
|
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
||||||
|
body = {"values": new_rows}
|
||||||
|
sheet.values().append(
|
||||||
|
spreadsheetId=spreadsheet_id,
|
||||||
|
range="A2", # Appends to the end of the sheet
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body=body
|
||||||
|
).execute()
|
||||||
|
print("Sync completed successfully.")
|
||||||
|
if sort_by_date:
|
||||||
|
sort_sheet_by_date(service, spreadsheet_id)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -197,16 +266,20 @@ def main():
|
|||||||
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
||||||
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
||||||
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Fetch and dedup without writing to the sheet")
|
||||||
|
parser.add_argument("--print-fio-table", action="store_true", help="Print aligned table of all fetched transactions with NEW/DUP status (use with --dry-run)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sync_to_sheets(
|
sync_to_sheets(
|
||||||
spreadsheet_id=args.sheet_id,
|
spreadsheet_id=args.sheet_id,
|
||||||
credentials_path=args.credentials,
|
credentials_path=args.credentials,
|
||||||
days=args.days,
|
days=args.days,
|
||||||
date_from_str=args.date_from,
|
date_from_str=args.date_from,
|
||||||
date_to_str=args.date_to,
|
date_to_str=args.date_to,
|
||||||
sort_by_date=args.sort_by_date
|
sort_by_date=args.sort_by_date,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
print_fio_table=args.print_fio_table,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Sync failed: {e}")
|
print(f"Sync failed: {e}")
|
||||||
|
|||||||
@@ -1044,8 +1044,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
|
||||||
fromSelect.value = defaultFrom;
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1025,8 +1025,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
|
||||||
fromSelect.value = defaultFrom;
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user