Compare commits
7 Commits
7f87e63b7c
...
feat/go-m6
| Author | SHA1 | Date | |
|---|---|---|---|
| fe935235e8 | |||
| e22ab8cc49 | |||
| e53e238ca6 | |||
| 309c26f209 | |||
| 01573faced | |||
| 689f1c01fd | |||
| cb8a09b571 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
||||
|
||||
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
||||
- Implemented `GET /sync-bank`: runs Fio sync → infer payments → cache flush, captures output into `sync.tmpl` page with success/error banner.
|
||||
- Implemented `GET /flush-cache` + `POST /flush-cache`: form + action that deletes cache files and shows deleted count.
|
||||
- Added `GET /version` as a JSON alias of `GET /api/version` (Python parity).
|
||||
- Added `FlushCache() (int, error)` to `membership.Sources` interface; implemented on `realSources` via `cache.FileCache.Flush()`.
|
||||
- Introduced `web.ActionHandlers{BankSync}` — closure-based dep injection for sync, constructed in `serverCmd` with fio + sheets clients.
|
||||
- New dependency: `github.com/skip2/go-qrcode`.
|
||||
- Key files: `go/internal/web/qr.go`, `go/internal/web/html_handler.go`, `go/internal/web/server.go`, `go/internal/services/membership/loader.go`, `go/internal/web/templates/sync.tmpl`, `go/internal/web/templates/flush_cache.tmpl`.
|
||||
|
||||
## 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`).
|
||||
|
||||
@@ -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
|
||||
**Started:** 2026-05-04
|
||||
**Last updated:** 2026-05-08 (M6.2 merged)
|
||||
**Last updated:** 2026-05-08 (M6.4 merged)
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -113,9 +113,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.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.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
||||
- [ ] **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, ↑/↓)
|
||||
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
||||
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||
- [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`
|
||||
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||
|
||||
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
||||
|
||||
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.
|
||||
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# M6.6 — `/qr`, `/sync-bank`, `/flush-cache`, `/version` (Go parity)
|
||||
|
||||
> On approval, relocate this file to `docs/plans/YYYY-MM-DD-HHMM-go-m6-6-action-pages.md` per the repo's `CLAUDE.md` "Plans" convention before starting work.
|
||||
|
||||
## Context
|
||||
|
||||
The Go-rewrite progress tracker ([2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118)) lists M6.6 as:
|
||||
|
||||
> `/qr, /sync-bank, /flush-cache, /version pages`
|
||||
|
||||
After M6.4 (payments page) and M6.5 (member-detail modal JS), the only remaining pre-deployment HTML/action endpoints in Python that the Go binary doesn't yet implement are these four. The adults/juniors templates already produce `/qr?...` URLs via the `qrHref` / `qrHrefAll` helpers ([render.go:47-71](go/internal/web/render.go#L47-L71)) — the matching handler is the missing piece. `sync` and `flush_cache` templates exist as "Coming in M6.6" stubs and need real markup + working handlers. `/api/version` already serves build metadata; `/version` just needs to alias it (Python parity).
|
||||
|
||||
After M6.6 the M6 gate ("Browser smoke on :8080: all pages render … QR loads, sync/flush work end-to-end") is reachable.
|
||||
|
||||
## Confirmed design choices
|
||||
|
||||
- **`/sync-bank`**: Python-faithful — GET both renders and triggers the sync, captures stdout/stderr into a log buffer, surfaces success/failure via a flag. Matches existing nav `[Sync Bank Data]` link.
|
||||
- **`/version`**: JSON alias of `/api/version`. No HTML page.
|
||||
- **QR library**: `github.com/skip2/go-qrcode`. Default level `qrcode.Medium`, size 256.
|
||||
|
||||
## Scope per endpoint
|
||||
|
||||
### 1. `GET /qr` — PNG only
|
||||
|
||||
Match Python ([app.py:321-356](app.py#L321-L356)) verbatim semantics:
|
||||
|
||||
1. Read `account` (default `cfg.BankAccount`), `amount` (default `"0"`), `message` (default `""`).
|
||||
2. Validate account against `^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`; on miss, silently fall back to `cfg.BankAccount`.
|
||||
3. Parse amount as float64; clamp to `[0, 10_000_000]`; format `%.2f`; on parse error → `"0.00"`.
|
||||
4. Truncate message to 60 runes; strip all `*`.
|
||||
5. If account contains `/`, split into `{number}*BC:{bankcode}`; else use raw.
|
||||
6. SPD payload: `SPD*1.0*ACC:{acc}*AM:{amount}*CC:CZK*MSG:{msg}` (no `BC:` key when IBAN — Python doesn't either).
|
||||
7. `qrcode.Encode(payload, qrcode.Medium, 256)` → write bytes with `Content-Type: image/png`.
|
||||
|
||||
No template, no HTML.
|
||||
|
||||
### 2. `GET /sync-bank` — triggers + renders
|
||||
|
||||
Match Python ([app.py:124-151](app.py#L124-L151)):
|
||||
|
||||
1. Build a `*bytes.Buffer` to capture step output.
|
||||
2. For each step write a header line then call:
|
||||
- `banksync.SyncToSheets(ctx, cfg.PaymentsSheetID, fioClient, sheetsClient, banksync.SyncOpts{From: jan1ThisYear, To: dec31ThisYear, Sort: true})`
|
||||
- `banksync.InferPayments(ctx, cfg.PaymentsSheetID, sheetsClient, sources, banksync.InferOpts{})`
|
||||
- `cacheFlusher.Flush()` — append `"%d cache files deleted"`.
|
||||
3. On any error: append `err.Error()` + a stack trace via `runtime/debug.Stack()`, set `success=false`, continue rendering.
|
||||
4. Render `sync` template with `SyncPageData{ PageData; Output string; Success bool }`.
|
||||
|
||||
The `banksync` package's stdout-printing functions can be redirected by passing an `io.Writer` — confirm during implementation; if they only print to `os.Stdout`, capture by tee'ing through a `slog` handler or briefly swapping `os.Stdout` (or extend the package with a `*Writer` option). **Preferred**: add a `Logger io.Writer` field to `banksync.SyncOpts` / `InferOpts`. This is a minor extension to existing services, not a rewrite.
|
||||
|
||||
### 3. `/flush-cache` — GET form, POST action
|
||||
|
||||
Match Python ([app.py:117-122](app.py#L117-L122)):
|
||||
|
||||
- `GET` → render `flush_cache` with `Flushed=false`.
|
||||
- `POST` → call `cacheFlusher.Flush()`, render with `Flushed=true, Deleted=n`.
|
||||
|
||||
### 4. `GET /version` — JSON
|
||||
|
||||
One-liner: register the existing `api.VersionHandler` (or its inline equivalent in [api/version.go](go/internal/web/api/version.go)) on the `/version` path as well as `/api/version`. Output: `{"tag", "commit", "build_date"}`.
|
||||
|
||||
## Files to create / modify
|
||||
|
||||
### New files
|
||||
|
||||
- **[go/internal/web/templates/qr.tmpl](go/internal/web/templates/qr.tmpl)** — *not actually needed*; remove from plan if `/qr` is PNG-only. (Decision: skip — no template.)
|
||||
- Replace stub **[go/internal/web/templates/sync.tmpl](go/internal/web/templates/sync.tmpl)** with full markup: title, status banner colored by `Success`, `<pre>` of `Output`, "Run again" link.
|
||||
- Replace stub **[go/internal/web/templates/flush_cache.tmpl](go/internal/web/templates/flush_cache.tmpl)** with: form (`POST /flush-cache` → `<button>[Flush Cache]</button>`), success banner when `Flushed`.
|
||||
|
||||
### Modified files
|
||||
|
||||
- **[go/go.mod](go/go.mod)** / `go.sum` — add `github.com/skip2/go-qrcode`.
|
||||
- **[go/internal/web/server.go](go/internal/web/server.go)** — register `GET /qr`, `GET /sync-bank` (replace stub), `GET /flush-cache` (form), `POST /flush-cache` (action), `GET /version`. Widen `Run(...)` signature to accept `fio.Client`, `sheets.Client`, and a `cache.Flusher` — these flow into a new `Deps` struct on `HTMLHandler`.
|
||||
- **[go/internal/web/html_handler.go](go/internal/web/html_handler.go)** — add `ServeQR`, real `ServeSync`, split `ServeFlushCacheGET` / `ServeFlushCachePOST`, add `ServeVersion` (delegates to api package).
|
||||
- **[go/internal/web/render.go](go/internal/web/render.go)** — add `SyncPageData{ PageData; Output string; Success bool }` and `FlushPageData{ PageData; Flushed bool; Deleted int }` view-models. (`pageNames` already includes `"sync"` and `"flush_cache"`; no change.)
|
||||
- **[go/internal/web/qr.go](go/internal/web/qr.go)** *(new file)* — pure helper: `BuildSPD(account, amount, message string, defaultAccount string) (payload string)` + `RenderQRCode(payload string) ([]byte, error)`. Easier to unit-test than a method on the handler.
|
||||
- **[go/internal/services/membership/loader.go](go/internal/services/membership/loader.go)** — add a small `CacheFlusher` interface (`Flush() (int, error)`); have `Sources` aggregate include it. *Alternative considered*: pass `*cache.FileCache` straight into `web.Run`. Choose whichever needs fewer test-stub edits — likely the direct-pass approach, since stubs in tests don't need cache behavior. **Decision: pass `cache.Flusher` directly into `web.Run` as a separate dependency, not through `Sources`.** Simpler, fewer interface changes.
|
||||
- **[go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)** + `infer.go` — accept an optional `io.Writer` (in `SyncOpts.Logger` / `InferOpts.Logger`) and route progress prints through it; default to `os.Stdout` when nil for CLI parity.
|
||||
- **[go/cmd/fuj/main.go](go/cmd/fuj/main.go)** — in the `server` subcommand, build `fio.Client`, `sheets.Client`, and `cache.Flusher` (already constructed inside `membership.NewSources` — expose it or construct a sibling) and pass them into `web.Run`.
|
||||
|
||||
## Reuse (already in the codebase)
|
||||
|
||||
- **Bank sync**: `banksync.SyncToSheets` ([go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)) and `banksync.InferPayments` ([go/internal/services/banksync/infer.go](go/internal/services/banksync/infer.go)) — same calls the CLI `sync` / `infer` subcommands use ([go/cmd/fuj/main.go:184](go/cmd/fuj/main.go#L184), [:222](go/cmd/fuj/main.go#L222)).
|
||||
- **Cache flush**: `(*cache.FileCache).Flush() (int, error)` ([go/internal/io/cache/filecache.go:92](go/internal/io/cache/filecache.go#L92)) — already returns the deleted count.
|
||||
- **Build metadata**: `BuildInfo{Version, Commit, BuildDate}` ([go/internal/web/server.go:28-35](go/internal/web/server.go#L28-L35)) is already plumbed everywhere; `/api/version` JSON shape is already correct ([go/internal/web/api/version.go](go/internal/web/api/version.go)).
|
||||
- **Template render pattern**: `Renderer.Render(w, name, data)` ([go/internal/web/render.go:98-108](go/internal/web/render.go#L98-L108)).
|
||||
- **Test scaffolding**: `web.NewRenderer()` + `httptest.NewRequest/NewRecorder` ([go/internal/web/html_handler_test.go:52-94](go/internal/web/html_handler_test.go#L52-L94)).
|
||||
|
||||
## Tests
|
||||
|
||||
Add to `go/internal/web/html_handler_test.go` (and one new `qr_test.go` for the SPD builder):
|
||||
|
||||
- **`TestQRBuildSPD`** *(unit, in [go/internal/web/qr_test.go](go/internal/web/qr_test.go))* — table-driven: invalid account → fallback; `/`-account → `{num}*BC:{bc}`; IBAN → raw; amount `-1` → `0.00`; amount `99999999` → `9999999.00`; amount `"abc"` → `0.00`; message with `*` stripped; message length-clamped to 60.
|
||||
- **`TestServeQR`** — request `/qr?account=…&amount=10&message=Hi`; assert 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG\r\n\x1a\n`.
|
||||
- **`TestServeFlushCacheGet`** — assert form rendered, no banner.
|
||||
- **`TestServeFlushCachePost`** — fixture flusher returns 3; assert banner says "3" and `Flushed`.
|
||||
- **`TestServeSync`** — fixture banksync stubs return `(2, nil)` and `(1, nil)`, fixture flusher returns 4; assert page renders, status is success, output contains the section headers.
|
||||
- **`TestServeVersion`** — assert `application/json`, body `{"tag":"v0","commit":"abc1234","build_date":"2026-01-01"}`.
|
||||
- Extend the `TestHTMLHandlerSmoke` table to cover `/qr` and `/version`.
|
||||
|
||||
## Verification (end-to-end)
|
||||
|
||||
1. `cd go && go build ./... && go test ./...`
|
||||
2. `cd go && go run ./cmd/fuj server` — open http://localhost:8080
|
||||
3. Smoke:
|
||||
- **/adults** → click `Pay` next to any unpaid month → modal shows QR image (this exercises `/qr`). Confirm a Czech banking app can scan it (visual check).
|
||||
- Direct `/qr?account=2702008874/2010&amount=700&message=Test` → PNG renders.
|
||||
- **/sync-bank** → click `[Sync Bank Data]` in nav → page shows "Syncing…", then sections + "Inferred Payments", "Cache Flushed", green status banner.
|
||||
- **/flush-cache** → click `[Flush Cache]` → success banner with file count.
|
||||
- **/version** in browser → JSON visible.
|
||||
- Adults / juniors / payments still render (no regressions from `Run` signature change).
|
||||
4. Run the parity diff: `go run ./cmd/parity` — `/api/version` must still match Python.
|
||||
5. Append a CHANGELOG entry per `CLAUDE.md`; tick `M6.6` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118).
|
||||
|
||||
## Branch / MR
|
||||
|
||||
Per `CLAUDE.md` workflow: `feat/go-m6-6-action-pages` off `main`, push with `-u`, open with `tea pr create --base main --head feat/go-m6-6-action-pages`.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Auth / CSRF protection on `/sync-bank` and `/flush-cache` — Python has none either; deferred to M8 hardening.
|
||||
- M6.7 (single-binary embed verification) — separate ticket.
|
||||
- Streaming progress for `/sync-bank` (currently buffered until completion, like Python).
|
||||
- Configurable QR error-correction level / size.
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fuj-management/go/internal/services/banksync"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
@@ -82,9 +83,40 @@ func serverCmd(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||
yr := time.Now().Year()
|
||||
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli,
|
||||
banksync.SyncOpts{From: from, To: to, Sort: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync: %w", err)
|
||||
}
|
||||
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
|
||||
|
||||
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("infer: %w", err)
|
||||
}
|
||||
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||
|
||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
|
||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.26.1
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/invopop/jsonschema v0.14.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
google.golang.org/api v0.278.0
|
||||
|
||||
@@ -37,6 +37,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
|
||||
@@ -25,11 +25,17 @@ type ExceptionLoader interface {
|
||||
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
||||
}
|
||||
|
||||
// CacheFlusher can invalidate all cached data.
|
||||
type CacheFlusher interface {
|
||||
FlushCache() (int, error)
|
||||
}
|
||||
|
||||
// Sources is the aggregate interface required by ReconcileReport.
|
||||
type Sources interface {
|
||||
AttendanceLoader
|
||||
TransactionLoader
|
||||
ExceptionLoader
|
||||
CacheFlusher
|
||||
}
|
||||
|
||||
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
||||
@@ -52,3 +58,5 @@ func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction,
|
||||
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||
return nil, ErrIOPending
|
||||
}
|
||||
|
||||
func (stubSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
|
||||
return f.exceptions, nil
|
||||
}
|
||||
|
||||
func (fakeSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
func TestReconcileReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := fakeSources{
|
||||
|
||||
@@ -111,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
|
||||
return parseTransactionRows(rows)
|
||||
}
|
||||
|
||||
// FlushCache deletes all cached data files and resets in-memory cache state.
|
||||
func (s *realSources) FlushCache() (int, error) { return s.cache.Flush() }
|
||||
|
||||
// LoadExceptions fetches the exceptions tab (cached).
|
||||
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
||||
|
||||
@@ -23,6 +23,9 @@ type Handler struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// FlushCache invalidates all cached data via the underlying Sources.
|
||||
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
|
||||
|
||||
// ServeVersion handles GET /api/version.
|
||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, VersionResponse{
|
||||
@@ -76,13 +79,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) (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// HTMLHandler serves the Go-native HTML frontend.
|
||||
@@ -10,11 +13,12 @@ type HTMLHandler struct {
|
||||
renderer *Renderer
|
||||
build BuildInfo
|
||||
apiHandler *api.Handler
|
||||
actions ActionHandlers
|
||||
}
|
||||
|
||||
// NewHTMLHandler constructs an HTMLHandler.
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
|
||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,13 +52,92 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
|
||||
pd := PageData{Active: "sync", Build: h.build}
|
||||
|
||||
if h.actions.BankSync == nil {
|
||||
h.renderer.Render(w, "sync", SyncPageData{
|
||||
PageData: pd,
|
||||
Output: "Bank sync is not configured.",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
success := true
|
||||
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
|
||||
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
|
||||
success = false
|
||||
}
|
||||
|
||||
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
|
||||
n, err := h.apiHandler.FlushCache()
|
||||
if err != nil {
|
||||
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
|
||||
success = false
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
|
||||
}
|
||||
|
||||
h.renderer.Render(w, "sync", SyncPageData{
|
||||
PageData: pd,
|
||||
Output: buf.String(),
|
||||
Success: success,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
||||
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
||||
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||
PageData: PageData{Active: "flush", Build: h.build},
|
||||
})
|
||||
}
|
||||
|
||||
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
|
||||
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := h.apiHandler.FlushCache()
|
||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||
PageData: PageData{Active: "flush", Build: h.build},
|
||||
Flushed: true,
|
||||
Deleted: n,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
|
||||
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account := q.Get("account")
|
||||
amount := q.Get("amount")
|
||||
message := q.Get("message")
|
||||
if account == "" {
|
||||
account = h.apiHandler.Config.BankAccount
|
||||
}
|
||||
if amount == "" {
|
||||
amount = "0"
|
||||
}
|
||||
|
||||
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
|
||||
png, err := RenderQRCode(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(png)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/domain/reconcile"
|
||||
"fuj-management/go/internal/web"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -41,6 +44,8 @@ func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.Exception
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
func fixtureHandler(t *testing.T) *api.Handler {
|
||||
t.Helper()
|
||||
return &api.Handler{
|
||||
@@ -55,7 +60,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
@@ -65,7 +70,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
||||
{"/juniors", h.ServeJuniors},
|
||||
{"/payments", h.ServePayments},
|
||||
{"/sync-bank", h.ServeSync},
|
||||
{"/flush-cache", h.ServeFlushCache},
|
||||
{"/flush-cache", h.ServeFlushCacheGET},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -99,7 +104,7 @@ func TestAdultsPage(t *testing.T) {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -126,3 +131,207 @@ func TestAdultsPage(t *testing.T) {
|
||||
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), web.ActionHandlers{})
|
||||
|
||||
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), web.ActionHandlers{})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeQR(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), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
|
||||
t.Errorf("Content-Type = %q, want image/png", ct)
|
||||
}
|
||||
// PNG magic bytes: \x89PNG\r\n\x1a\n
|
||||
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
body := w.Body.Bytes()
|
||||
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
|
||||
t.Error("response body does not start with PNG magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFlushCacheGET(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), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeFlushCacheGET(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Flush Cache") {
|
||||
t.Error("body missing Flush Cache heading")
|
||||
}
|
||||
if strings.Contains(body, "file(s) deleted") {
|
||||
t.Error("GET should not show deleted count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFlushCachePOST(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), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeFlushCachePOST(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
// fixtureSources.FlushCache returns 0
|
||||
if !strings.Contains(body, "file(s) deleted") {
|
||||
t.Error("POST should show deleted count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSync(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"}
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(_ context.Context, out io.Writer) error {
|
||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||
fmt.Fprintln(out, "Synced 3 new transaction(s).")
|
||||
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||
fmt.Fprintln(out, "Inferred 2 row(s).")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeSync(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Sync Bank Data",
|
||||
"Synced 3 new transaction(s).",
|
||||
"Inferred 2 row(s).",
|
||||
"Flush Cache",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeVersion(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
fixtureHandler(t).ServeVersion(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"tag", "commit", "build_date"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("JSON response missing key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50
go/internal/web/qr.go
Normal file
50
go/internal/web/qr.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
|
||||
|
||||
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
|
||||
// Invalid account falls back to defaultAccount.
|
||||
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
|
||||
// Message is truncated to 60 runes and stripped of '*' characters.
|
||||
func BuildSPD(account, amount, message, defaultAccount string) string {
|
||||
if !validAccount.MatchString(account) {
|
||||
account = defaultAccount
|
||||
}
|
||||
|
||||
var amtStr string
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
|
||||
amtStr = "0.00"
|
||||
} else {
|
||||
amtStr = fmt.Sprintf("%.2f", f)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(message) > 60 {
|
||||
runes := []rune(message)
|
||||
message = string(runes[:60])
|
||||
}
|
||||
message = strings.ReplaceAll(message, "*", "")
|
||||
|
||||
var accStr string
|
||||
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
|
||||
accStr = parts[0] + "*BC:" + parts[1]
|
||||
} else {
|
||||
accStr = account
|
||||
}
|
||||
|
||||
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
|
||||
}
|
||||
|
||||
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
|
||||
func RenderQRCode(payload string) ([]byte, error) {
|
||||
return qrcode.Encode(payload, qrcode.Medium, 256)
|
||||
}
|
||||
91
go/internal/web/qr_test.go
Normal file
91
go/internal/web/qr_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQRBuildSPD(t *testing.T) {
|
||||
const def = "2702008874/2010"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
account string
|
||||
amount string
|
||||
message string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "czech account",
|
||||
account: "2702008874/2010",
|
||||
amount: "700",
|
||||
message: "Test Member: 01/2026",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:700.00*CC:CZK*MSG:Test Member: 01/2026",
|
||||
},
|
||||
{
|
||||
name: "IBAN account",
|
||||
account: "CZ6508000000192000145399",
|
||||
amount: "500",
|
||||
message: "hi",
|
||||
want: "SPD*1.0*ACC:CZ6508000000192000145399*AM:500.00*CC:CZK*MSG:hi",
|
||||
},
|
||||
{
|
||||
name: "invalid account falls back to default",
|
||||
account: "NOTANACCOUNT",
|
||||
amount: "100",
|
||||
message: "x",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:x",
|
||||
},
|
||||
{
|
||||
name: "empty account falls back to default",
|
||||
account: "",
|
||||
amount: "0",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "negative amount clamped to 0.00",
|
||||
account: def,
|
||||
amount: "-1",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "amount over 10M clamped to 0.00",
|
||||
account: def,
|
||||
amount: "99999999",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "non-numeric amount becomes 0.00",
|
||||
account: def,
|
||||
amount: "abc",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "asterisks stripped from message",
|
||||
account: def,
|
||||
amount: "100",
|
||||
message: "pay*now",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:paynow",
|
||||
},
|
||||
{
|
||||
name: "message truncated to 60 runes",
|
||||
account: def,
|
||||
amount: "0",
|
||||
message: strings.Repeat("á", 65),
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:" + strings.Repeat("á", 60),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := BuildSPD(tc.account, tc.amount, tc.message, def)
|
||||
if got != tc.want {
|
||||
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,27 @@ type JuniorsPageData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// PaymentsPageData is the view model for the /payments HTML page.
|
||||
type PaymentsPageData struct {
|
||||
PageData
|
||||
Data api.PaymentsResponse
|
||||
Error string
|
||||
}
|
||||
|
||||
// SyncPageData is the view model for the /sync-bank HTML page.
|
||||
type SyncPageData struct {
|
||||
PageData
|
||||
Output string
|
||||
Success bool
|
||||
}
|
||||
|
||||
// FlushPageData is the view model for the /flush-cache HTML page.
|
||||
type FlushPageData struct {
|
||||
PageData
|
||||
Flushed bool
|
||||
Deleted int
|
||||
}
|
||||
|
||||
// Renderer parses and executes HTML templates from the embedded FS.
|
||||
type Renderer struct {
|
||||
tmpls map[string]*template.Template
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"fuj-management/go/internal/web/middleware"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -18,8 +20,16 @@ type BuildInfo struct {
|
||||
BuildDate string
|
||||
}
|
||||
|
||||
// ActionHandlers holds function closures for side-effectful operations that
|
||||
// require dependencies (fio, sheets) not present on the core API handler.
|
||||
type ActionHandlers struct {
|
||||
// BankSync runs sync+infer and writes a human-readable log to out.
|
||||
// nil disables the /sync-bank action (renders an error instead).
|
||||
BankSync func(ctx context.Context, out io.Writer) error
|
||||
}
|
||||
|
||||
// Run registers routes and starts the HTTP server on addr.
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config, actions ActionHandlers) error {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("init templates: %w", err)
|
||||
@@ -33,7 +43,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
}
|
||||
hh := NewHTMLHandler(renderer, build, ah)
|
||||
hh := NewHTMLHandler(renderer, build, ah, actions)
|
||||
|
||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
@@ -50,13 +60,16 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
||||
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
||||
mux.HandleFunc("GET /payments", hh.ServePayments)
|
||||
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
||||
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
|
||||
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCacheGET)
|
||||
mux.HandleFunc("POST /flush-cache", hh.ServeFlushCachePOST)
|
||||
mux.HandleFunc("GET /qr", hh.ServeQR)
|
||||
|
||||
// Static files
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||
|
||||
// JSON API routes
|
||||
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||
mux.HandleFunc("GET /version", ah.ServeVersion)
|
||||
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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}}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{{define "title"}}Flush Cache{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Flush Cache</h1>
|
||||
<p class="description">Coming in M6.6</p>
|
||||
{{if .Flushed}}
|
||||
<p class="status-ok">Cache flushed: {{.Deleted}} file(s) deleted.</p>
|
||||
{{end}}
|
||||
<p class="description">Deletes all cached data files so the next request fetches fresh data from Google Sheets.</p>
|
||||
<form method="POST" action="/flush-cache">
|
||||
<button type="submit" class="btn">Flush Cache</button>
|
||||
</form>
|
||||
{{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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
{{define "title"}}Sync Bank Data{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Sync Bank Data</h1>
|
||||
<p class="description">Coming in M6.6</p>
|
||||
{{if .Output}}
|
||||
{{if .Success}}
|
||||
<p class="status-ok">Sync completed successfully.</p>
|
||||
{{else}}
|
||||
<p class="status-error">Sync failed — see log below.</p>
|
||||
{{end}}
|
||||
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
|
||||
{{else}}
|
||||
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
|
||||
{{end}}
|
||||
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user