Compare commits
11 Commits
fix/go-att
...
216b5b437a
| Author | SHA1 | Date | |
|---|---|---|---|
| 216b5b437a | |||
| 78e5059759 | |||
| 2f635db2b4 | |||
| 709a2f2335 | |||
| 58973473c9 | |||
| b68d95d217 | |||
| 07ca1cd9e1 | |||
| 5dcac25c13 | |||
| fc47606b1c | |||
| 65694ad378 | |||
| 092dff25a5 |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
|
||||||
|
|
||||||
|
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
|
||||||
|
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
|
||||||
|
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
|
||||||
|
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
|
||||||
|
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
|
||||||
|
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
|
||||||
|
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
|
||||||
|
|
||||||
|
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
|
||||||
|
|
||||||
|
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
|
||||||
|
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
|
||||||
|
|
||||||
|
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
|
||||||
|
|
||||||
|
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
|
||||||
|
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
|
||||||
|
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
|
||||||
|
|
||||||
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
||||||
|
|
||||||
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||||
|
|
||||||
**Current milestone:** M5 — JSON-only `/api/...` routes ✅
|
**Current milestone:** M6 — Go-native HTML frontend
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-07 (M5.4)
|
**Last updated:** 2026-05-08 (M6.1)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
|
|||||||
|
|
||||||
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||||
|
|
||||||
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
||||||
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
||||||
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Plan: Go rewrite — M6.1 Template skeleton
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
|
||||||
|
|
||||||
|
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
|
||||||
|
|
||||||
|
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
|
||||||
|
|
||||||
|
## Current Go web state (what we're building on)
|
||||||
|
|
||||||
|
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
|
||||||
|
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
|
||||||
|
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
|
||||||
|
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
|
||||||
|
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
|
||||||
|
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
|
||||||
|
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
|
||||||
|
6. **Replace the existing `helloHandler`**: `GET /{$}` → `http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
|
||||||
|
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
|
||||||
|
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
```
|
||||||
|
go/internal/web/
|
||||||
|
├── assets.go NEW — //go:embed templates/* static/*
|
||||||
|
├── render.go NEW — Renderer wraps parsed *template.Template
|
||||||
|
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
|
||||||
|
├── html/
|
||||||
|
│ ├── handler.go NEW — html.Handler + page methods
|
||||||
|
│ └── handler_test.go NEW — httptest smoke per route
|
||||||
|
├── templates/
|
||||||
|
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
|
||||||
|
│ ├── partials/
|
||||||
|
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
|
||||||
|
│ │ └── footer.tmpl NEW — build meta line
|
||||||
|
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
|
||||||
|
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
|
||||||
|
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
|
||||||
|
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
|
||||||
|
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
|
||||||
|
└── static/
|
||||||
|
└── css/
|
||||||
|
└── app.css NEW — verbatim from templates/adults.html:8-487
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key design notes
|
||||||
|
|
||||||
|
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
|
||||||
|
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
|
||||||
|
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
|
||||||
|
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
|
||||||
|
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
|
||||||
|
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
|
||||||
|
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
|
||||||
|
|
||||||
|
## Critical files to read
|
||||||
|
|
||||||
|
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
|
||||||
|
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
|
||||||
|
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
|
||||||
|
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end smoke from a fresh `make go-build`:
|
||||||
|
|
||||||
|
1. `make web-go &` — server boots; no template-parse errors in slog output.
|
||||||
|
2. `curl -i localhost:8080/` → `302` to `/adults`.
|
||||||
|
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
|
||||||
|
4. `curl -sI localhost:8080/static/css/app.css` → `200`, `Content-Type: text/css`.
|
||||||
|
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
|
||||||
|
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
|
||||||
|
7. `make go-test` — `web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
|
||||||
|
8. `make go-lint` clean.
|
||||||
|
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
|
||||||
|
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
|
||||||
|
|
||||||
|
## Branch & MR
|
||||||
|
|
||||||
|
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.
|
||||||
9
go/internal/web/assets.go
Normal file
9
go/internal/web/assets.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
34
go/internal/web/html_handler.go
Normal file
34
go/internal/web/html_handler.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// HTMLHandler serves the Go-native HTML frontend.
|
||||||
|
type HTMLHandler struct {
|
||||||
|
renderer *Renderer
|
||||||
|
build BuildInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLHandler constructs an HTMLHandler.
|
||||||
|
func NewHTMLHandler(r *Renderer, b BuildInfo) *HTMLHandler {
|
||||||
|
return &HTMLHandler{renderer: r, build: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "adults", PageData{Active: "adults", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "juniors", PageData{Active: "juniors", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
||||||
|
}
|
||||||
54
go/internal/web/html_handler_test.go
Normal file
54
go/internal/web/html_handler_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/web"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTMLHandlerSmoke(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults},
|
||||||
|
{"/juniors", h.ServeJuniors},
|
||||||
|
{"/payments", h.ServePayments},
|
||||||
|
{"/sync-bank", h.ServeSync},
|
||||||
|
{"/flush-cache", h.ServeFlushCache},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if n := strings.Count(body, `class="active"`); n != 1 {
|
||||||
|
t.Errorf(`class="active" count = %d, want 1`, n)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("missing active link %q in body", want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
53
go/internal/web/render.go
Normal file
53
go/internal/web/render.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PageData is the view model passed to every HTML template.
|
||||||
|
type PageData struct {
|
||||||
|
Active string
|
||||||
|
Build BuildInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderer parses and executes HTML templates from the embedded FS.
|
||||||
|
type Renderer struct {
|
||||||
|
tmpls map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||||
|
|
||||||
|
// NewRenderer parses all templates from the embedded FS.
|
||||||
|
// A parse failure should be treated as a startup-time fatal error.
|
||||||
|
func NewRenderer() (*Renderer, error) {
|
||||||
|
tmpls := make(map[string]*template.Template, len(pageNames))
|
||||||
|
for _, name := range pageNames {
|
||||||
|
t, err := template.New("").ParseFS(templateFS,
|
||||||
|
"templates/base.tmpl",
|
||||||
|
"templates/partials/nav.tmpl",
|
||||||
|
"templates/partials/footer.tmpl",
|
||||||
|
"templates/"+name+".tmpl",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse template %q: %w", name, err)
|
||||||
|
}
|
||||||
|
tmpls[name] = t
|
||||||
|
}
|
||||||
|
return &Renderer{tmpls: tmpls}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render executes the named template with data, writing to w.
|
||||||
|
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
|
||||||
|
t, ok := r.tmpls[name]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||||
|
slog.Error("render template", "name", name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
"fuj-management/go/internal/web/middleware"
|
"fuj-management/go/internal/web/middleware"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,12 @@ type BuildInfo struct {
|
|||||||
|
|
||||||
// Run registers routes and starts the HTTP server on addr.
|
// 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) error {
|
||||||
h := &api.Handler{
|
renderer, err := NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ah := &api.Handler{
|
||||||
BuildVersion: build.Version,
|
BuildVersion: build.Version,
|
||||||
BuildCommit: build.Commit,
|
BuildCommit: build.Commit,
|
||||||
BuildDate: build.BuildDate,
|
BuildDate: build.BuildDate,
|
||||||
@@ -27,22 +33,34 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
|
hh := NewHTMLHandler(renderer, build)
|
||||||
|
|
||||||
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("static subfs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /{$}", helloHandler(build))
|
|
||||||
mux.HandleFunc("GET /api/version", h.ServeVersion)
|
// HTML routes
|
||||||
mux.HandleFunc("GET /api/adults", h.ServeAdults)
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
mux.HandleFunc("GET /api/juniors", h.ServeJuniors)
|
http.Redirect(w, r, "/adults", http.StatusFound)
|
||||||
mux.HandleFunc("GET /api/payments", h.ServePayments)
|
})
|
||||||
|
mux.HandleFunc("GET /adults", hh.ServeAdults)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||||
|
|
||||||
|
// JSON API routes
|
||||||
|
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||||
|
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||||
|
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||||
|
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||||
|
|
||||||
logger.Info("starting server", "addr", addr)
|
logger.Info("starting server", "addr", addr)
|
||||||
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||||
}
|
}
|
||||||
|
|
||||||
func helloHandler(build BuildInfo) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
|
|
||||||
build.Version, build.Commit, build.BuildDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
477
go/internal/web/static/css/app.css
Normal file
477
go/internal/web/static/css/app.css
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #888888;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-pos {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-neg {
|
||||||
|
color: #ff3333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-ok {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid {
|
||||||
|
color: #ff3333;
|
||||||
|
background-color: rgba(255, 51, 51, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid-current {
|
||||||
|
color: #994444;
|
||||||
|
background-color: rgba(153, 68, 68, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #ff3333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-row:hover .pay-btn {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-empty {
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-val {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-row {
|
||||||
|
font-family: inherit;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 100px 200px 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-header {
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
width: 250px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
color: #888;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #00ff00;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
#memberModal {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#memberModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
border: 1px solid #00ff00;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
color: #ff3333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-title {
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-toggle {
|
||||||
|
color: #333;
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
margin-left: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-toggle:hover {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th,
|
||||||
|
.modal-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-meta {
|
||||||
|
color: #555;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-amount {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-sender {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-msg {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-breakdown {
|
||||||
|
display: none;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Modal styles */
|
||||||
|
#qrModal .modal-content {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image img {
|
||||||
|
display: block;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details div {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details span {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
5
go/internal/web/templates/adults.tmpl
Normal file
5
go/internal/web/templates/adults.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Adults{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Adults Dashboard</h1>
|
||||||
|
<p class="description">Coming in M6.2</p>
|
||||||
|
{{end}}
|
||||||
15
go/internal/web/templates/base.tmpl
Normal file
15
go/internal/web/templates/base.tmpl
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "base"}}<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ — {{template "title" .}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
{{template "content" .}}
|
||||||
|
{{template "footer" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/flush_cache.tmpl
Normal file
5
go/internal/web/templates/flush_cache.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Flush Cache{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Flush Cache</h1>
|
||||||
|
<p class="description">Coming in M6.6</p>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/juniors.tmpl
Normal file
5
go/internal/web/templates/juniors.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Juniors{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Juniors Dashboard</h1>
|
||||||
|
<p class="description">Coming in M6.3</p>
|
||||||
|
{{end}}
|
||||||
3
go/internal/web/templates/partials/footer.tmpl
Normal file
3
go/internal/web/templates/partials/footer.tmpl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{define "footer"}}
|
||||||
|
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
|
||||||
|
{{end}}
|
||||||
17
go/internal/web/templates/partials/nav.tmpl
Normal file
17
go/internal/web/templates/partials/nav.tmpl
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "nav"}}
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults"{{if eq .Active "adults"}} class="active"{{end}}>[Adults]</a>
|
||||||
|
<a href="/juniors"{{if eq .Active "juniors"}} class="active"{{end}}>[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments"{{if eq .Active "payments"}} class="active"{{end}}>[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank"{{if eq .Active "sync"}} class="active"{{end}}>[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache"{{if eq .Active "flush"}} class="active"{{end}}>[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/payments.tmpl
Normal file
5
go/internal/web/templates/payments.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Payments Ledger{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Payments Ledger</h1>
|
||||||
|
<p class="description">Coming in M6.4</p>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/sync.tmpl
Normal file
5
go/internal/web/templates/sync.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Sync Bank Data{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Sync Bank Data</h1>
|
||||||
|
<p class="description">Coming in M6.6</p>
|
||||||
|
{{end}}
|
||||||
@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
idx_sender = get_col_index("Sender")
|
idx_sender = get_col_index("Sender")
|
||||||
idx_message = get_col_index("Message")
|
idx_message = get_col_index("Message")
|
||||||
idx_bank_id = get_col_index("Bank ID")
|
idx_bank_id = get_col_index("Bank ID")
|
||||||
|
idx_vs = get_col_index("VS")
|
||||||
|
idx_sync_id = get_col_index("Sync ID")
|
||||||
|
|
||||||
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||||
missing = [name for name, idx in required.items() if idx == -1]
|
missing = [name for name, idx in required.items() if idx == -1]
|
||||||
@@ -247,16 +249,33 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
def get_val(idx):
|
def get_val(idx):
|
||||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||||
|
|
||||||
|
def get_str(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, float) and v.is_integer():
|
||||||
|
return str(int(v))
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
def get_float(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(str(v).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
tx = {
|
tx = {
|
||||||
"date": format_date(get_val(idx_date)),
|
"date": format_date(get_val(idx_date)),
|
||||||
"amount": get_val(idx_amount),
|
"amount": get_float(idx_amount),
|
||||||
"manual_fix": get_val(idx_manual),
|
"manual_fix": get_val(idx_manual),
|
||||||
"person": get_val(idx_person),
|
"person": get_val(idx_person),
|
||||||
"purpose": get_val(idx_purpose),
|
"purpose": get_val(idx_purpose),
|
||||||
"inferred_amount": get_val(idx_inferred_amount),
|
"inferred_amount": get_val(idx_inferred_amount),
|
||||||
"sender": get_val(idx_sender),
|
"sender": get_val(idx_sender),
|
||||||
"message": get_val(idx_message),
|
"vs": get_str(idx_vs),
|
||||||
|
"message": get_str(idx_message),
|
||||||
"bank_id": get_val(idx_bank_id),
|
"bank_id": get_val(idx_bank_id),
|
||||||
|
"sync_id": get_val(idx_sync_id),
|
||||||
}
|
}
|
||||||
transactions.append(tx)
|
transactions.append(tx)
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,9 @@ def build_juniors_view_model(
|
|||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
amount_to_pay = 0
|
amount_to_pay = 0
|
||||||
|
|
||||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
is_unknown = original_expected == "?"
|
||||||
if expected == "?":
|
if is_unknown or (isinstance(expected, int) and expected > 0):
|
||||||
|
if is_unknown:
|
||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = f"?{count_str}"
|
cell_text = f"?{count_str}"
|
||||||
elif paid >= expected:
|
elif paid >= expected:
|
||||||
@@ -339,7 +340,7 @@ def build_juniors_view_model(
|
|||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
else:
|
else:
|
||||||
tooltip = ""
|
tooltip = ""
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class TestWebApp(unittest.TestCase):
|
|||||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
'purpose': '2026-01', 'message': 'test payment',
|
'purpose': '2026-01', 'message': 'test payment',
|
||||||
'sender': 'External Bank User', 'inferred_amount': 750,
|
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||||
|
'vs': '', 'sync_id': 'abc123',
|
||||||
}]
|
}]
|
||||||
response = self.client.get('/api/adults')
|
response = self.client.get('/api/adults')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -155,6 +156,7 @@ class TestWebApp(unittest.TestCase):
|
|||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||||
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||||
|
'vs': '', 'sync_id': 'def456',
|
||||||
}]
|
}]
|
||||||
response = self.client.get('/api/juniors')
|
response = self.client.get('/api/juniors')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -172,6 +174,7 @@ class TestWebApp(unittest.TestCase):
|
|||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||||
|
'vs': '', 'sync_id': 'ghi789',
|
||||||
}]
|
}]
|
||||||
response = self.client.get('/api/payments')
|
response = self.client.get('/api/payments')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|||||||
Reference in New Issue
Block a user