Compare commits
4 Commits
fix/py-par
...
216b5b437a
| Author | SHA1 | Date | |
|---|---|---|---|
| 216b5b437a | |||
| 78e5059759 | |||
| 2f635db2b4 | |||
| 709a2f2335 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
||||
# 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.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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
|
||||
**Last updated:** 2026-05-07 (M5.4)
|
||||
**Last updated:** 2026-05-08 (M6.1)
|
||||
|
||||
## 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.
|
||||
|
||||
- [ ] **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.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||
- [ ] **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/web/api"
|
||||
"fuj-management/go/internal/web/middleware"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
@@ -19,7 +20,12 @@ type BuildInfo struct {
|
||||
|
||||
// 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 {
|
||||
h := &api.Handler{
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("init templates: %w", err)
|
||||
}
|
||||
|
||||
ah := &api.Handler{
|
||||
BuildVersion: build.Version,
|
||||
BuildCommit: build.Commit,
|
||||
BuildDate: build.BuildDate,
|
||||
@@ -27,22 +33,34 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
||||
Config: cfg,
|
||||
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.HandleFunc("GET /{$}", helloHandler(build))
|
||||
mux.HandleFunc("GET /api/version", h.ServeVersion)
|
||||
mux.HandleFunc("GET /api/adults", h.ServeAdults)
|
||||
mux.HandleFunc("GET /api/juniors", h.ServeJuniors)
|
||||
mux.HandleFunc("GET /api/payments", h.ServePayments)
|
||||
|
||||
// HTML routes
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/adults", http.StatusFound)
|
||||
})
|
||||
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)
|
||||
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}}
|
||||
Reference in New Issue
Block a user