feat(go): M6.5 — member-detail modal JS module #31
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.
|
||||||
@@ -127,6 +127,47 @@ func TestAdultsPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestPaymentsPage(t *testing.T) {
|
||||||
renderer, err := web.NewRenderer()
|
renderer, err := web.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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}}
|
||||||
|
|||||||
@@ -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="juniors">
|
||||||
<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}}">
|
||||||
@@ -117,5 +117,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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user