Compare commits
11 Commits
fix/period
...
e22ab8cc49
| Author | SHA1 | Date | |
|---|---|---|---|
| e22ab8cc49 | |||
| e53e238ca6 | |||
| 309c26f209 | |||
| 01573faced | |||
| 689f1c01fd | |||
| cb8a09b571 | |||
| 7f87e63b7c | |||
| 95641036cc | |||
| 505d635c66 | |||
| 9f0e4b0ac3 | |||
| 2cbd98df1a |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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).
|
- 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).
|
||||||
|
|||||||
@@ -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.2 merged)
|
**Last updated:** 2026-05-08 (M6.4 merged)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -112,9 +112,9 @@ 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/` — `78e5059`
|
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
||||||
- [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`
|
- [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
|
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
||||||
- [ ] **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
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
@@ -55,25 +55,43 @@ func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
|
|||||||
|
|
||||||
// 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) (
|
||||||
|
|||||||
@@ -33,11 +33,33 @@ func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -126,3 +126,75 @@ func TestAdultsPage(t *testing.T) {
|
|||||||
t.Error("body should not contain >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))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ type AdultsPageData struct {
|
|||||||
Error string
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -476,3 +476,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;
|
||||||
|
}
|
||||||
|
|||||||
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); }
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range $row := .Data.Results}}
|
{{range $row := .Data.Results}}
|
||||||
<tr class="member-row" data-name="{{$row.Name}}">
|
<tr class="member-row" data-name="{{$row.Name}}">
|
||||||
<td class="member-name">{{$row.Name}}</td>
|
<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}}
|
{{range $i, $cell := $row.Months}}
|
||||||
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
<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}}">
|
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}}">
|
||||||
@@ -137,5 +137,53 @@
|
|||||||
|
|
||||||
{{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>
|
||||||
|
|
||||||
<script src="/static/js/filters.js" defer></script>
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,169 @@
|
|||||||
{{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">
|
||||||
|
<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)}}
|
||||||
|
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
||||||
|
{{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}}
|
||||||
|
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
||||||
|
{{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>
|
||||||
|
|
||||||
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
<script src="/static/js/member-detail.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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user