feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
- Extract AssembleAdults(ctx) from ServeAdults so HTML and JSON API share one reconcile path. - HTMLHandler gains *api.Handler; ServeAdults loads real data and renders adults.tmpl. - AdultsPageData view model + qrHref/qrHrefAll funcMap (URL-encode /qr params, YYYY-MM→MM/YYYY). - adults.tmpl: full reconcile table, per-cell status classes + cell-unpaid-current, Pay button hrefs, totals row, credits/debts/unmatched sections, filter controls, sheet links. - static/js/filters.js: NFD-normalize name filter + month-range column hiding; future months hidden by default. - TestAdultsPage asserts member name and cell text against fixture data. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -34,14 +34,23 @@ func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ServeAdults handles GET /api/adults.
|
||||
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||
resp, err := h.AssembleAdults(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// AssembleAdults loads all data and builds the adults view model.
|
||||
// Shared between the JSON API route and the HTML handler.
|
||||
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||
if err != nil {
|
||||
return AdultsResponse{}, err
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||
return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||
}
|
||||
|
||||
// ServeJuniors handles GET /api/juniors.
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"fuj-management/go/internal/web/api"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTMLHandler serves the Go-native HTML frontend.
|
||||
type HTMLHandler struct {
|
||||
renderer *Renderer
|
||||
build BuildInfo
|
||||
renderer *Renderer
|
||||
build BuildInfo
|
||||
apiHandler *api.Handler
|
||||
}
|
||||
|
||||
// NewHTMLHandler constructs an HTMLHandler.
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo) *HTMLHandler {
|
||||
return &HTMLHandler{renderer: r, build: b}
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "adults", PageData{Active: "adults", Build: h.build})
|
||||
data, err := h.apiHandler.AssembleAdults(r.Context())
|
||||
if err != nil {
|
||||
h.renderer.Render(w, "adults", AdultsPageData{
|
||||
PageData: PageData{Active: "adults", Build: h.build},
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.renderer.Render(w, "adults", AdultsPageData{
|
||||
PageData: PageData{Active: "adults", Build: h.build},
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/domain/reconcile"
|
||||
"fuj-management/go/internal/web"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee
|
||||
// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's
|
||||
// test_adults_route fixture.
|
||||
type fixtureSources struct{}
|
||||
|
||||
func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||
return []reconcile.Member{
|
||||
{Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{
|
||||
"2026-01": {Expected: 750, Attendance: 4},
|
||||
}},
|
||||
}, []string{"2026-01"}, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
|
||||
amt := float64(750)
|
||||
return []reconcile.Transaction{
|
||||
{Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fixtureHandler(t *testing.T) *api.Handler {
|
||||
t.Helper()
|
||||
return &api.Handler{
|
||||
Sources: fixtureSources{},
|
||||
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLHandlerSmoke(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)
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
@@ -52,3 +92,37 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdultsPage(t *testing.T) {
|
||||
renderer, err := web.NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeAdults(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Adults Dashboard",
|
||||
"Test Member",
|
||||
"750/750 CZK (4)", // paid/expected (attendance)
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Python assertion: cell text never says literally "OK"
|
||||
if strings.Contains(body, ">OK<") {
|
||||
t.Error("body should not contain >OK<")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// PageData is the view model passed to every HTML template.
|
||||
@@ -13,6 +16,13 @@ type PageData struct {
|
||||
Build BuildInfo
|
||||
}
|
||||
|
||||
// AdultsPageData is the view model for the /adults HTML page.
|
||||
type AdultsPageData struct {
|
||||
PageData
|
||||
Data api.AdultsResponse
|
||||
Error string
|
||||
}
|
||||
|
||||
// Renderer parses and executes HTML templates from the embedded FS.
|
||||
type Renderer struct {
|
||||
tmpls map[string]*template.Template
|
||||
@@ -20,12 +30,43 @@ type Renderer struct {
|
||||
|
||||
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||
|
||||
// qrHref builds the /qr query URL for a single-month Pay button.
|
||||
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
|
||||
func qrHref(account string, amount int, name, rawMonth string) string {
|
||||
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
|
||||
if len(rawMonth) == 7 && rawMonth[4] == '-' {
|
||||
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
|
||||
}
|
||||
msg := name + ": " + rawMonth
|
||||
return "/qr?" + url.Values{
|
||||
"account": {account},
|
||||
"amount": {strconv.Itoa(amount)},
|
||||
"message": {msg},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// qrHrefAll builds the /qr query URL for a Pay-All button.
|
||||
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
|
||||
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
|
||||
msg := name + ": " + rawPeriods
|
||||
return "/qr?" + url.Values{
|
||||
"account": {account},
|
||||
"amount": {strconv.Itoa(amount)},
|
||||
"message": {msg},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"qrHref": qrHref,
|
||||
"qrHrefAll": qrHrefAll,
|
||||
}
|
||||
|
||||
// NewRenderer parses all templates from the embedded FS.
|
||||
// A parse failure should be treated as a startup-time fatal error.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
tmpls := make(map[string]*template.Template, len(pageNames))
|
||||
for _, name := range pageNames {
|
||||
t, err := template.New("").ParseFS(templateFS,
|
||||
t, err := template.New("").Funcs(tmplFuncs).ParseFS(templateFS,
|
||||
"templates/base.tmpl",
|
||||
"templates/partials/nav.tmpl",
|
||||
"templates/partials/footer.tmpl",
|
||||
|
||||
@@ -33,7 +33,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
}
|
||||
hh := NewHTMLHandler(renderer, build)
|
||||
hh := NewHTMLHandler(renderer, build, ah)
|
||||
|
||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
|
||||
91
go/internal/web/static/js/filters.js
Normal file
91
go/internal/web/static/js/filters.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// Client-side filters for the Adults (and future Juniors) dashboard table.
|
||||
// Mirrors adults.html:864-1051 from the Python frontend.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// NFD-normalize + strip diacritics + lowercase, matching Python's
|
||||
// unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode().lower()
|
||||
function normalize(s) {
|
||||
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
||||
}
|
||||
|
||||
const container = document.getElementById('filterContainer');
|
||||
if (!container) return;
|
||||
|
||||
const currentMonth = container.dataset.currentMonth || '';
|
||||
|
||||
const nameInput = document.getElementById('nameFilter');
|
||||
const fromSelect = document.getElementById('fromMonth');
|
||||
const toSelect = document.getElementById('toMonth');
|
||||
const applyBtn = document.getElementById('applyFilter');
|
||||
const clearBtn = document.getElementById('clearFilter');
|
||||
|
||||
// ── Month column visibility ───────────────────────────────────────────────
|
||||
|
||||
// Hide columns whose raw month is in the future by default.
|
||||
function hideFutureMonths() {
|
||||
if (!currentMonth) return;
|
||||
document.querySelectorAll('[data-raw-month]').forEach(el => {
|
||||
if (el.dataset.rawMonth > currentMonth) {
|
||||
el.classList.add('month-hidden');
|
||||
}
|
||||
});
|
||||
// Sync toMonth select to the last non-hidden month.
|
||||
const ths = [...document.querySelectorAll('thead th[data-month-idx]')];
|
||||
const visibleIdxs = ths
|
||||
.filter(th => !th.classList.contains('month-hidden'))
|
||||
.map(th => parseInt(th.dataset.monthIdx, 10));
|
||||
if (visibleIdxs.length) {
|
||||
toSelect.value = String(visibleIdxs[visibleIdxs.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMonthFilter() {
|
||||
const from = fromSelect.value !== '' ? parseInt(fromSelect.value, 10) : -Infinity;
|
||||
const to = toSelect.value !== '' ? parseInt(toSelect.value, 10) : Infinity;
|
||||
|
||||
document.querySelectorAll('[data-month-idx]').forEach(el => {
|
||||
const idx = parseInt(el.dataset.monthIdx, 10);
|
||||
if (idx < from || idx > to) {
|
||||
el.classList.add('month-hidden');
|
||||
} else {
|
||||
el.classList.remove('month-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearMonthFilter() {
|
||||
document.querySelectorAll('[data-month-idx]').forEach(el => {
|
||||
el.classList.remove('month-hidden');
|
||||
});
|
||||
fromSelect.value = '';
|
||||
toSelect.value = '';
|
||||
}
|
||||
|
||||
// ── Name row visibility ───────────────────────────────────────────────────
|
||||
|
||||
function applyNameFilter() {
|
||||
const query = normalize(nameInput.value.trim());
|
||||
document.querySelectorAll('tr.member-row').forEach(row => {
|
||||
const name = normalize(row.dataset.name || '');
|
||||
row.style.display = (!query || name.includes(query)) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
nameInput.addEventListener('input', applyNameFilter);
|
||||
|
||||
applyBtn.addEventListener('click', applyMonthFilter);
|
||||
|
||||
clearBtn.addEventListener('click', function () {
|
||||
nameInput.value = '';
|
||||
applyNameFilter();
|
||||
clearMonthFilter();
|
||||
hideFutureMonths();
|
||||
});
|
||||
|
||||
// ── Initialise ────────────────────────────────────────────────────────────
|
||||
|
||||
hideFutureMonths();
|
||||
}());
|
||||
@@ -1,5 +1,137 @@
|
||||
{{define "title"}}Adults{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Adults Dashboard</h1>
|
||||
<p class="description">Coming in M6.2</p>
|
||||
|
||||
{{if .Error}}
|
||||
<p class="description error-banner">Error loading data: {{.Error}}</p>
|
||||
{{else}}
|
||||
|
||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
|
||||
<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-btn">Apply</button>
|
||||
<button id="clearFilter" class="filter-btn">All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Data.Results}}
|
||||
<div class="table-wrapper">
|
||||
<table class="reconcile-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>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $row := .Data.Results}}
|
||||
<tr class="member-row" data-name="{{$row.Name}}">
|
||||
<td class="member-name">{{$row.Name}}</td>
|
||||
{{range $i, $cell := $row.Months}}
|
||||
<td class="cell cell-{{$cell.Status}}{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}} cell-unpaid-current{{end}}{{if $cell.Overridden}} cell-overridden{{end}}"
|
||||
data-month-idx="{{$i}}" title="{{$cell.Tooltip}}">
|
||||
{{$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="balance-cell{{if lt $row.Balance 0}} balance-negative{{end}}">{{$row.Balance}} CZK</td>
|
||||
<td class="payall-cell">
|
||||
{{if gt $row.PayableAmount 0}}
|
||||
<a class="pay-btn pay-all-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="totals-row">
|
||||
<td><strong>TOTAL</strong></td>
|
||||
{{range $i, $t := .Data.Totals}}
|
||||
<td class="total-cell total-cell-{{$t.Status}}" data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$t.Text}}</td>
|
||||
{{end}}
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="description">No members found.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Data.Credits}}
|
||||
<h2 class="section-header">Credits</h2>
|
||||
<ul class="credits-list">
|
||||
{{range .Data.Credits}}
|
||||
<li>{{.Name}}: <strong>+{{.Amount}} CZK</strong></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Data.Debts}}
|
||||
<h2 class="section-header">Debts</h2>
|
||||
<ul class="debts-list">
|
||||
{{range .Data.Debts}}
|
||||
<li>{{.Name}}: <strong>−{{.Amount}} CZK</strong></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Data.Unmatched}}
|
||||
<h2 class="unmatched-header section-header">Unmatched Transactions</h2>
|
||||
<table class="unmatched-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Sender</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Unmatched}}
|
||||
<tr class="unmatched-row">
|
||||
<td>{{.Date}}</td>
|
||||
<td>{{printf "%.0f" .Amount}} CZK</td>
|
||||
<td>{{.Sender}}</td>
|
||||
<td>{{.Message}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<p class="sheet-links">
|
||||
<a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">[Attendance sheet]</a>
|
||||
·
|
||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">[Payments sheet]</a>
|
||||
</p>
|
||||
|
||||
{{end}}
|
||||
|
||||
<script src="/static/js/filters.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user