Compare commits
4 Commits
f6ba85b18f
...
feat/go-m6
| Author | SHA1 | Date | |
|---|---|---|---|
| d981392593 | |||
| f25552eef2 | |||
| 4276d7b915 | |||
| 919845518c |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
|
||||
|
||||
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
|
||||
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
|
||||
- Key files: `go/internal/web/assets_test.go` (new).
|
||||
|
||||
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
|
||||
|
||||
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
|
||||
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
|
||||
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
|
||||
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
|
||||
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
|
||||
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.4 merged)
|
||||
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -115,8 +115,9 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||
- [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
|
||||
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
|
||||
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
|
||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge)
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
|
||||
|
||||
> Plan-mode note: per project convention this plan should live at
|
||||
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
|
||||
> execution step after approval is to copy this file there.
|
||||
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
|
||||
|
||||
## Context
|
||||
|
||||
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
|
||||
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
|
||||
`/version`. While doing it, the **client-side** half of the Pay flow was
|
||||
collapsed from a modal-with-details into a plain `<a href="/qr?...">` —
|
||||
clicking Pay now navigates the whole tab to the raw PNG.
|
||||
|
||||
The Python app instead opens an in-page `#qrModal` showing the QR image
|
||||
*plus* labelled details (account, amount, message, title), then closes
|
||||
the modal on outside-click or `[close]`
|
||||
([templates/adults.html:631-648](templates/adults.html#L631-L648),
|
||||
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
|
||||
This is the UX the user expects from the dashboard — staying on the
|
||||
table while displaying the QR is significantly more practical than
|
||||
losing context to a fullscreen image.
|
||||
|
||||
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
|
||||
`#qrModal`, identical in structure to the Python original, driven by a
|
||||
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
|
||||
convention from M6.5.
|
||||
|
||||
All the building blocks are already in place:
|
||||
|
||||
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
|
||||
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
|
||||
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
|
||||
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
|
||||
already produce the correct query string + Czech `MM/YYYY` format
|
||||
for the `message=` param. They're useful for sanity (and for any
|
||||
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
|
||||
- **Bank account** is already exposed as `$.Data.BankAccount` to every
|
||||
template that has a Pay button.
|
||||
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
|
||||
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
|
||||
inline `onclick`, no globals leaked.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
|
||||
|
||||
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)
|
||||
(both have identical Pay markup at lines 65 and 72):
|
||||
|
||||
```gotmpl
|
||||
<button type="button" class="pay-btn"
|
||||
data-name="{{$row.Name}}"
|
||||
data-amount="{{$cell.Amount}}"
|
||||
data-month="{{$cell.Month}}"
|
||||
data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||
```
|
||||
|
||||
```gotmpl
|
||||
<button type="button" class="pay-btn"
|
||||
data-name="{{$row.Name}}"
|
||||
data-amount="{{$row.PayableAmount}}"
|
||||
data-month="{{$row.UnpaidPeriods}}"
|
||||
data-raw-month="{{$row.RawUnpaidPeriods}}"
|
||||
data-pay-all="1">Pay All</button>
|
||||
```
|
||||
|
||||
Why `<button type="button">` and not `<a>`: the click no longer
|
||||
navigates — the JS handles it. `type="button"` prevents accidental form
|
||||
submission. A no-JS fallback could be added later by re-introducing
|
||||
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()` —
|
||||
deferred until anyone actually asks (see §Out of scope).
|
||||
|
||||
`data-month` carries the human-readable period (e.g. `January 2026`,
|
||||
or `January 2026 + February 2026` for Pay-All) which the modal title
|
||||
displays. `data-raw-month` carries the machine format (`2026-01`,
|
||||
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
|
||||
message — same conversion Python's `showPayQR` does at
|
||||
[templates/adults.html:966-968](templates/adults.html#L966-L968).
|
||||
`data-pay-all` is purely informational; the JS doesn't actually need
|
||||
it (the same code path handles single + multi-period), but keeping it
|
||||
helps debugging and any future analytics.
|
||||
|
||||
### 2. Add `#qrModal` markup
|
||||
|
||||
Append after the `#memberModal` block in both `adults.tmpl` and
|
||||
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
|
||||
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
|
||||
using `class="active"` toggling (the convention M6.5 picked) instead of
|
||||
`style.display = 'block'`:
|
||||
|
||||
```gotmpl
|
||||
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||
</div>
|
||||
<div class="qr-image">
|
||||
<img id="qrImg" src="" alt="Payment QR Code">
|
||||
</div>
|
||||
<div class="qr-details">
|
||||
<div>Account: <span id="qrAccount"></span></div>
|
||||
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||
<div>Message: <span id="qrMessage"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The CSS already targets `#qrModal .modal-content` directly. To make the
|
||||
"hidden by default / visible when `.active`" toggle behave the same
|
||||
way `#memberModal` does, the existing CSS rules for `#memberModal`
|
||||
(`display:none`, `#memberModal.active { display: flex; }`) need a
|
||||
sibling pair for `#qrModal`. Audit `app.css` during implementation:
|
||||
- if `#qrModal` already has both rules, no change.
|
||||
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
|
||||
- if it has neither, add both.
|
||||
|
||||
This is a 2-line CSS adjustment at most; tracked under §Critical files.
|
||||
|
||||
### 3. Bank account on the page
|
||||
|
||||
The modal needs the bank account string for the `qrAccount` label and
|
||||
for building the `/qr?account=…` URL. Two clean options:
|
||||
|
||||
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
|
||||
Per-button duplication, but trivially done in templates and keeps the JS
|
||||
module's surface tiny.
|
||||
|
||||
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
|
||||
(or on the existing `#filterContainer`).
|
||||
|
||||
**Decision: (b)** — single stamping site, follows the same pattern
|
||||
M6.5 used for `data-page` on `#filterContainer`. Add
|
||||
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
|
||||
both templates (one-line edit each).
|
||||
|
||||
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
|
||||
|
||||
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
|
||||
right after `member-detail.js` in both templates. Pure ES, no build
|
||||
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
|
||||
([templates/adults.html:963-986](templates/adults.html#L963-L986))
|
||||
plus event wiring:
|
||||
|
||||
```js
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const container = document.getElementById('filterContainer');
|
||||
if (!container) return;
|
||||
const bankAccount = container.dataset.bankAccount || '';
|
||||
|
||||
const modal = document.getElementById('qrModal');
|
||||
const titleEl = document.getElementById('qrTitle');
|
||||
const imgEl = document.getElementById('qrImg');
|
||||
const accountEl = document.getElementById('qrAccount');
|
||||
const amountEl = document.getElementById('qrAmount');
|
||||
const messageEl = document.getElementById('qrMessage');
|
||||
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||
|
||||
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
|
||||
// Mirrors templates/adults.html:966-968.
|
||||
function toCzechMonth(rawMonth) {
|
||||
return rawMonth.split('+')
|
||||
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
|
||||
.join('+');
|
||||
}
|
||||
|
||||
function showQR(name, amount, month, rawMonth) {
|
||||
const numericMonth = toCzechMonth(rawMonth);
|
||||
const message = `${name}: ${numericMonth}`;
|
||||
|
||||
titleEl.innerText = `Payment for ${month}`;
|
||||
accountEl.innerText = bankAccount;
|
||||
amountEl.innerText = amount;
|
||||
messageEl.innerText = message;
|
||||
|
||||
const url = '/qr?'
|
||||
+ 'account=' + encodeURIComponent(bankAccount)
|
||||
+ '&amount=' + encodeURIComponent(amount)
|
||||
+ '&message=' + encodeURIComponent(message);
|
||||
imgEl.src = url;
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeQR() { modal.classList.remove('active'); }
|
||||
|
||||
// Event delegation: any .pay-btn click anywhere in the page.
|
||||
document.addEventListener('click', ev => {
|
||||
const btn = ev.target.closest('.pay-btn');
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
showQR(
|
||||
btn.dataset.name,
|
||||
btn.dataset.amount,
|
||||
btn.dataset.month,
|
||||
btn.dataset.rawMonth,
|
||||
);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||
});
|
||||
}());
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **Event delegation** (single listener on `document`) handles both Pay
|
||||
and Pay-All without enumerating buttons. It also survives any future
|
||||
re-render of the table without needing rebinding.
|
||||
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
|
||||
for `Escape` too, but only acts when `#memberModal` is active; the
|
||||
new module similarly only acts when `#qrModal` is active. No collision.
|
||||
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
|
||||
and only fires when the user clicks.
|
||||
- **Inline `onclick`** on `[close]` and outside-click in the markup are
|
||||
carried over from the M6.5 modal style; consistent with `#memberModal`.
|
||||
(Switching the project's modal close pattern to data attributes is a
|
||||
separate cleanup, out of scope here.)
|
||||
|
||||
### 5. Tests
|
||||
|
||||
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||
with markup-level checks. Headless-browser JS is out of scope (same
|
||||
rationale as M6.5).
|
||||
|
||||
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
|
||||
|
||||
- Body for `/adults` and `/juniors` contains
|
||||
`<script src="/static/js/payment-qr.js"`.
|
||||
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
|
||||
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
|
||||
- The fixture row's Pay button is rendered as
|
||||
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
|
||||
- The fixture row's `.pay-btn` does **not** include `href=`.
|
||||
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
|
||||
(matching the test's `fixtureHandler` config).
|
||||
|
||||
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
|
||||
|
||||
Once Pay/Pay-All buttons are JS-driven, the helpers in
|
||||
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
|
||||
**Decision: delete them**, plus their entries in `tmplFuncs`. A
|
||||
no-JS fallback is not on the roadmap; if it ever becomes one, the
|
||||
helpers are easy to resurrect from git.
|
||||
|
||||
The unit-test file currently has no direct tests for these helpers —
|
||||
nothing to remove from tests.
|
||||
|
||||
## Critical files
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
|
||||
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
|
||||
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
|
||||
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
|
||||
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
|
||||
|
||||
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
|
||||
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
|
||||
API handlers, or any domain code.
|
||||
|
||||
## Reused existing infrastructure
|
||||
|
||||
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
|
||||
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
|
||||
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
|
||||
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
|
||||
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end, after copying the plan into `docs/plans/`:
|
||||
|
||||
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 `Pay` on any unpaid month → `#qrModal` opens with
|
||||
- title `Payment for <Month YYYY>`,
|
||||
- `qrAccount` matching `BANK_ACCOUNT`,
|
||||
- `qrAmount` matching the cell amount,
|
||||
- `qrMessage` formatted as `<Name>: MM/YYYY`,
|
||||
- QR image rendered from `/qr?…` (Network tab shows PNG response).
|
||||
- Tab does **not** navigate; URL stays `/adults`.
|
||||
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
|
||||
- Click outside the modal-content → modal closes.
|
||||
- Click `[close]` → modal closes.
|
||||
- Press `Esc` → modal closes.
|
||||
- Open `[i]` member-detail modal first, then `Esc` → only that closes
|
||||
(verify both modals don't fight each other).
|
||||
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
|
||||
on a `"?"`-expected row doesn't appear (Pay button is conditional on
|
||||
`unpaid`/`partial` cells).
|
||||
5. Cross-check against Python: `make web-py &` →
|
||||
`http://localhost:5001/adults` modal looks visually equivalent for
|
||||
the same fixture (modulo any nav / theme differences already accepted
|
||||
in M6).
|
||||
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
|
||||
payload starts with `SPD*1.0*ACC:` and the message decodes to
|
||||
`<Name>: MM/YYYY`.
|
||||
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
|
||||
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||
immediately under the M6.6 line, in the same format as the other
|
||||
sub-bullets.
|
||||
|
||||
## Branching
|
||||
|
||||
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
|
||||
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||
`tea pr create`. Do not merge or delete from CLI.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
|
||||
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
|
||||
helpers if asked.
|
||||
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
|
||||
inline `onclick` for `[close]` and outside-click. Consistent with
|
||||
M6.5 but worth a follow-up to delegate-event everything.
|
||||
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
|
||||
no one's asked for it on the Go side.
|
||||
- **Per-button QR error-correction / size** — `/qr` is fixed at
|
||||
Medium/256, same as M6.6. Configurable via query param can be added if
|
||||
printing larger physical posters becomes a use case.
|
||||
- **`/payments` Pay buttons** — the payments page is a ledger view; it
|
||||
doesn't render Pay buttons in either backend.
|
||||
184
docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md
Normal file
184
docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# M6.7 — Single-binary embed verification
|
||||
|
||||
## Context
|
||||
|
||||
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
|
||||
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
|
||||
`embed.FS` into handlers; verify single-binary deployment includes all
|
||||
assets."
|
||||
|
||||
The wiring is **already in place** from M6.1 onward:
|
||||
|
||||
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
|
||||
`//go:embed templates` → `templateFS` and `//go:embed static` → `staticFS`.
|
||||
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
|
||||
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
|
||||
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
|
||||
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
|
||||
binary into the `alpine:3` runtime image — no `templates/` or `static/`
|
||||
directory ever lands beside it.
|
||||
|
||||
What is missing is **proof** the embed is complete and stays complete:
|
||||
|
||||
1. Nothing fails the build/test if a contributor adds a new file under
|
||||
`internal/web/templates/` or `internal/web/static/` that isn't matched
|
||||
by an `//go:embed` glob (or, more realistically, adds a sibling
|
||||
directory like `static/img/` and the glob still picks it up — but a
|
||||
typo'd directive would silently drop it).
|
||||
2. No automated test exercises the `/static/*` route against the embedded
|
||||
FS — current tests in
|
||||
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
|
||||
render templates (which proves `templateFS` is good) but never hit a
|
||||
static URL through the mux.
|
||||
3. The "single binary, no working-dir assets" property is undocumented —
|
||||
if it ever broke, no one would notice until the Docker image started
|
||||
500'ing in prod.
|
||||
|
||||
The intended outcome: a small test file plus a documented manual
|
||||
verification step, after which M6.7 can be ticked and M6 closed.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Add `go/internal/web/assets_test.go`
|
||||
|
||||
One new file, two tests, no production code changes.
|
||||
|
||||
**Test A — embed completeness regression guard.** Walks the on-disk
|
||||
`templates/` and `static/` directories and asserts every regular file is
|
||||
also present in the corresponding embedded FS. Catches:
|
||||
|
||||
- A new template added without updating the `//go:embed` directive
|
||||
(current globs are `templates` and `static` — recursive by default for
|
||||
directories, so this is a low-probability regression, but the test
|
||||
doubles as living documentation of the contract).
|
||||
- A typo in the directive (e.g. someone renames `static` → `assets` in
|
||||
one place but not the other).
|
||||
|
||||
Implementation sketch:
|
||||
|
||||
```go
|
||||
func TestEmbedCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
|
||||
embed fs.FS // exported helper or via internal test in package web
|
||||
root string
|
||||
}{...}
|
||||
for _, tc := range cases {
|
||||
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() { return err }
|
||||
embPath := tc.root + "/" + path
|
||||
if _, err := fs.Stat(tc.embed, embPath); err != nil {
|
||||
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because `templateFS` and `staticFS` are unexported package vars, this
|
||||
test lives in `package web` (not `web_test`) — sibling to
|
||||
[assets.go](../../go/internal/web/assets.go). All the existing handler
|
||||
tests are in `package web_test`; that's fine, this one is internal.
|
||||
|
||||
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
|
||||
with the same wiring as
|
||||
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
|
||||
requests, asserts:
|
||||
|
||||
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
|
||||
body contains a known string from app.css (e.g. a CSS selector).
|
||||
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
|
||||
`text/javascript` or `application/javascript`, body non-empty.
|
||||
- `GET /static/js/payment-qr.js` → 200, body non-empty.
|
||||
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
|
||||
rejects unknown paths instead of returning some default).
|
||||
|
||||
Rather than duplicate the mux assembly, factor a tiny helper (or test the
|
||||
existing mux). The cleanest move: extract `staticHandler()` from
|
||||
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
|
||||
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
|
||||
have the test call it. Smallest delta: keep production code unchanged and
|
||||
replicate the two-line wiring inside the test file (acceptable — it's
|
||||
two lines and the test exists precisely to lock that contract).
|
||||
|
||||
### 2. Manual / one-shot verification (no code; documented in plan only)
|
||||
|
||||
Run once locally and tick M6.7. Command transcript:
|
||||
|
||||
```bash
|
||||
make go-build # → ./bin/fuj
|
||||
cp bin/fuj /tmp/fuj-standalone
|
||||
cd /tmp # working dir has no templates/ or static/
|
||||
./fuj-standalone server &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
|
||||
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
|
||||
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
|
||||
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
|
||||
&& test -s /tmp/app.css
|
||||
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
|
||||
&& test -s /tmp/qr.js
|
||||
kill $SERVER_PID
|
||||
```
|
||||
|
||||
`fuj server` will fail to talk to Sheets without credentials, so the
|
||||
`/adults` etc. pages will render with the `Error` field set — that's
|
||||
fine; the assertion is that the **template + static asset pipeline** is
|
||||
self-contained, not that data loads. Each curl above only checks for
|
||||
markup present in every render path (header text and stylesheet body).
|
||||
|
||||
### 3. Tracker + changelog
|
||||
|
||||
- Tick `M6.7` in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
|
||||
append the merge SHA on the line.
|
||||
- Mark "Last updated" date and bump milestone status: M6 complete, next is
|
||||
M7.
|
||||
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
|
||||
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
|
||||
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
|
||||
|
||||
No production source files change. (If extracting the static handler
|
||||
reads cleaner, a 4-line refactor in
|
||||
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
|
||||
|
||||
## Branch + MR
|
||||
|
||||
Per project convention this is a feature, so:
|
||||
|
||||
```bash
|
||||
git checkout -b feat/go-m6-7-embed-verify
|
||||
# … commits …
|
||||
git push -u origin feat/go-m6-7-embed-verify
|
||||
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
|
||||
--description "<short body referencing M6.7>" --base main \
|
||||
--head feat/go-m6-7-embed-verify
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation:
|
||||
|
||||
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
|
||||
2. `make go-lint` → clean.
|
||||
3. Run the manual transcript in §2 above — all curls succeed, no
|
||||
"template not found" or 404 on static assets.
|
||||
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/` →
|
||||
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
|
||||
with stylesheet attached (visual smoke test in browser).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Re-architecting how templates are parsed or served.
|
||||
- Compressing / fingerprinting static assets (a separate concern).
|
||||
- Live integration test with real Sheets data — covered later in M7.
|
||||
93
go/internal/web/assets_test.go
Normal file
93
go/internal/web/assets_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbedCompleteness guards against a new template or static file being
|
||||
// added to disk but missing from the embedded FS (e.g. a new directory that
|
||||
// the //go:embed glob does not match).
|
||||
func TestEmbedCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
diskDir string
|
||||
embedFS fs.FS
|
||||
embedRoot string
|
||||
}{
|
||||
{"templates", "templates", templateFS, "templates"},
|
||||
{"static", "static", staticFS, "static"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diskFS := os.DirFS(tc.diskDir)
|
||||
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
embPath := tc.embedRoot + "/" + path
|
||||
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
|
||||
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticAssetsServed verifies that /static/* is served from the embedded
|
||||
// FS through the same mux wiring used in server.go, so a standalone binary
|
||||
// with no adjacent static/ directory still delivers assets.
|
||||
func TestStaticAssetsServed(t *testing.T) {
|
||||
subFS, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatalf("fs.Sub static: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
wantCT string
|
||||
wantSnippet string
|
||||
}{
|
||||
{"/static/css/app.css", "text/css", "body {"},
|
||||
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
|
||||
{"/static/js/filters.js", "javascript", ""},
|
||||
{"/static/js/payment-qr.js", "javascript", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, tc.wantCT) {
|
||||
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
|
||||
}
|
||||
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
|
||||
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sanity: unknown path → 404 (file server doesn't fall through silently)
|
||||
t.Run("missing-file", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown static path: status %d, want 404", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -314,6 +314,52 @@ func TestServeSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentQRMarkup(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
|
||||
}{
|
||||
{"/adults", h.ServeAdults},
|
||||
{"/juniors", h.ServeJuniors},
|
||||
}
|
||||
|
||||
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{
|
||||
`id="qrModal"`,
|
||||
`id="qrImg"`,
|
||||
`id="qrTitle"`,
|
||||
`id="qrAccount"`,
|
||||
`id="qrAmount"`,
|
||||
`id="qrMessage"`,
|
||||
`/static/js/payment-qr.js`,
|
||||
`data-bank-account="CZ0000000000000000000000"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Pay buttons must use <button>, never a bare href to /qr.
|
||||
if strings.Contains(body, `href="/qr`) {
|
||||
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeVersion(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// PageData is the view model passed to every HTML template.
|
||||
@@ -58,36 +56,7 @@ type Renderer struct {
|
||||
|
||||
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||
|
||||
// qrHref builds the /qr query URL for a single-month Pay button.
|
||||
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
|
||||
func qrHref(account string, amount int, name, rawMonth string) string {
|
||||
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
|
||||
if len(rawMonth) == 7 && rawMonth[4] == '-' {
|
||||
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
|
||||
}
|
||||
msg := name + ": " + rawMonth
|
||||
return "/qr?" + url.Values{
|
||||
"account": {account},
|
||||
"amount": {strconv.Itoa(amount)},
|
||||
"message": {msg},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// qrHrefAll builds the /qr query URL for a Pay-All button.
|
||||
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
|
||||
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
|
||||
msg := name + ": " + rawPeriods
|
||||
return "/qr?" + url.Values{
|
||||
"account": {account},
|
||||
"amount": {strconv.Itoa(amount)},
|
||||
"message": {msg},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"qrHref": qrHref,
|
||||
"qrHrefAll": qrHrefAll,
|
||||
}
|
||||
var tmplFuncs = template.FuncMap{}
|
||||
|
||||
// NewRenderer parses all templates from the embedded FS.
|
||||
// A parse failure should be treated as a startup-time fatal error.
|
||||
|
||||
@@ -442,6 +442,23 @@ tr:hover {
|
||||
}
|
||||
|
||||
/* QR Modal styles */
|
||||
#qrModal {
|
||||
display: none !important;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#qrModal.active {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
#qrModal .modal-content {
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
|
||||
60
go/internal/web/static/js/payment-qr.js
Normal file
60
go/internal/web/static/js/payment-qr.js
Normal file
@@ -0,0 +1,60 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const container = document.getElementById('filterContainer');
|
||||
if (!container) return;
|
||||
const bankAccount = container.dataset.bankAccount || '';
|
||||
|
||||
const modal = document.getElementById('qrModal');
|
||||
const titleEl = document.getElementById('qrTitle');
|
||||
const imgEl = document.getElementById('qrImg');
|
||||
const accountEl = document.getElementById('qrAccount');
|
||||
const amountEl = document.getElementById('qrAmount');
|
||||
const messageEl = document.getElementById('qrMessage');
|
||||
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||
|
||||
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges converted piece-wise.
|
||||
// Mirrors templates/adults.html showPayQR logic.
|
||||
function toCzechMonth(rawMonth) {
|
||||
return rawMonth.split('+')
|
||||
.map(function (p) { return p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'); })
|
||||
.join('+');
|
||||
}
|
||||
|
||||
function showQR(name, amount, month, rawMonth) {
|
||||
var numericMonth = toCzechMonth(rawMonth);
|
||||
var message = name + ': ' + numericMonth;
|
||||
|
||||
titleEl.innerText = 'Payment for ' + month;
|
||||
accountEl.innerText = bankAccount;
|
||||
amountEl.innerText = amount;
|
||||
messageEl.innerText = message;
|
||||
|
||||
imgEl.src = '/qr?'
|
||||
+ 'account=' + encodeURIComponent(bankAccount)
|
||||
+ '&amount=' + encodeURIComponent(amount)
|
||||
+ '&message=' + encodeURIComponent(message);
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeQR() {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest('.pay-btn');
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
showQR(
|
||||
btn.dataset.name,
|
||||
btn.dataset.amount,
|
||||
btn.dataset.month,
|
||||
btn.dataset.rawMonth
|
||||
);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||
});
|
||||
}());
|
||||
@@ -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}}" data-page="adults">
|
||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label" for="nameFilter">Member</label>
|
||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||
@@ -62,14 +62,14 @@
|
||||
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}}">
|
||||
{{$cell.Text}}
|
||||
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
||||
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||
{{$row.Balance}}
|
||||
{{if gt $row.PayableAmount 0}}
|
||||
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
||||
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -184,6 +184,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||
</div>
|
||||
<div class="qr-image">
|
||||
<img id="qrImg" src="" alt="Payment QR Code">
|
||||
</div>
|
||||
<div class="qr-details">
|
||||
<div>Account: <span id="qrAccount"></span></div>
|
||||
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||
<div>Message: <span id="qrMessage"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/filters.js" defer></script>
|
||||
<script src="/static/js/member-detail.js" defer></script>
|
||||
<script src="/static/js/payment-qr.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||
</div>
|
||||
|
||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors">
|
||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label" for="nameFilter">Member</label>
|
||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||
@@ -62,14 +62,14 @@
|
||||
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}}">
|
||||
{{$cell.Text}}
|
||||
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
||||
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||
{{$row.Balance}}
|
||||
{{if gt $row.PayableAmount 0}}
|
||||
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
||||
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -164,6 +164,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||
</div>
|
||||
<div class="qr-image">
|
||||
<img id="qrImg" src="" alt="Payment QR Code">
|
||||
</div>
|
||||
<div class="qr-details">
|
||||
<div>Account: <span id="qrAccount"></span></div>
|
||||
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||
<div>Message: <span id="qrMessage"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/filters.js" defer></script>
|
||||
<script src="/static/js/member-detail.js" defer></script>
|
||||
<script src="/static/js/payment-qr.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user