feat(go): M6.4 — Go-native /payments page (grouped-by-person ledger)
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
- Extract AssemblePayments(ctx) from ServePayments in api/handler.go, mirroring the AssembleAdults/AssembleJuniors pattern - Add PaymentsPageData view-model wrapper in render.go - Rewire html_handler.go ServePayments to call AssemblePayments and render with PaymentsPageData - Replace payments.tmpl placeholder with real grouped-by-person ledger: alphabetical member blocks, txn-table (Date/Amount/Purpose/Message), newest-first rows, Unmatched/Unknown bucket - Append ledger CSS classes to app.css (.ledger-container, .member-block, .txn-table, .txn-date/amount/purpose/message, tr:hover) - Add TestPaymentsPage markup test Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -76,13 +76,22 @@ func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error)
|
||||
|
||||
// ServePayments handles GET /api/payments.
|
||||
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
txns, err := h.Sources.LoadTransactions(ctx)
|
||||
resp, err := h.AssemblePayments(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
|
||||
h.writeError(w, r, err)
|
||||
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) (
|
||||
|
||||
@@ -48,7 +48,18 @@ func (h *HTMLHandler) ServeJuniors(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) {
|
||||
|
||||
@@ -126,3 +126,34 @@ func TestAdultsPage(t *testing.T) {
|
||||
t.Error("body should not contain >OK<")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ type JuniorsPageData struct {
|
||||
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.
|
||||
type Renderer struct {
|
||||
tmpls map[string]*template.Template
|
||||
|
||||
@@ -476,3 +476,59 @@ tr:hover {
|
||||
color: #00ff00;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
{{define "title"}}Payments Ledger{{end}}
|
||||
{{define "content"}}
|
||||
<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}}
|
||||
|
||||
Reference in New Issue
Block a user