feat(go): M6.5 — member-detail modal JS module for /adults and /juniors
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds static/js/member-detail.js: fetches /api/<page> once on page load, caches the response, and renders a per-member detail modal on [i] row click. Keyboard nav: Esc closes, ↑/↓ walk visible (filtered) rows. All modal CSS was already in place from M6.1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
renderer, err := web.NewRenderer()
|
||||
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>
|
||||
</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">
|
||||
<label class="filter-label" for="nameFilter">Member</label>
|
||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||
@@ -56,7 +56,7 @@
|
||||
<tbody>
|
||||
{{range $row := .Data.Results}}
|
||||
<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}}
|
||||
<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}}">
|
||||
@@ -137,5 +137,53 @@
|
||||
|
||||
{{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}}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||
</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">
|
||||
<label class="filter-label" for="nameFilter">Member</label>
|
||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||
@@ -56,7 +56,7 @@
|
||||
<tbody>
|
||||
{{range $row := .Data.Results}}
|
||||
<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}}
|
||||
<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}}">
|
||||
@@ -117,5 +117,53 @@
|
||||
|
||||
{{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}}
|
||||
|
||||
Reference in New Issue
Block a user