fix(finance): org polish — broken links, upload handler, populated selects (#21)

- org_home.html: fix Requests/Ledger links (were pointing to non-existent
  /years/{id}/requests routes; corrected to /orgs/{slug}/requests and
  /orgs/{slug}/ledger). Add Analysis link. Add Close year button for admins.
- OrgRequestNew: pass events + teams to GET template so dropdowns are
  populated (were silently discarded with _ = events).
- OrgRequestDetailData: add NewEvents/NewTeams fields for new-request form.
- OrgRequestUpload: implement file upload handler — saves to
  /data/org-files/{org_id}/{req_id}/{id} and records metadata in MongoDB.
  Register POST /orgs/{slug}/requests/{req_id}/upload route.
- org_request_detail.html: show upload form in attachments section;
  populate event/team selects on new request form.

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-14 16:00:23 +01:00 committed by GitHub
parent 0f58a51c6d
commit 07e3525dae
4 changed files with 101 additions and 11 deletions

View File

@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"time"
@ -1027,10 +1028,9 @@ func (h *Handler) OrgRequestNew(w http.ResponseWriter, r *http.Request) {
Org: *org,
MyRole: me.Role,
FiscalYear: activeYear,
// Use Request.ID="" to signal "new form" in template
NewEvents: events,
NewTeams: teams,
})
_ = events
_ = teams
return
}
@ -1387,6 +1387,73 @@ func (h *Handler) OrgRequestSettle(w http.ResponseWriter, r *http.Request) {
})(w, r)
}
func (h *Handler) OrgRequestUpload(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
reqID := r.PathValue("req_id")
if _, err := h.store.getTxRequest(ctx, reqID, org.ID); err != nil {
http.Error(w, "request not found", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
http.Error(w, "file too large (max 20 MB)", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer file.Close()
attachID := bson.NewObjectID().Hex()
dir := fmt.Sprintf("/data/org-files/%s/%s", org.ID, reqID)
if err := os.MkdirAll(dir, 0o750); err != nil {
slog.Error("mkdir attachment dir", "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
storagePath := fmt.Sprintf("%s/%s", dir, attachID)
dst, err := os.Create(storagePath)
if err != nil {
slog.Error("create attachment file", "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer dst.Close()
size, err := io.Copy(dst, file)
if err != nil {
slog.Error("write attachment file", "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
mime := header.Header.Get("Content-Type")
if mime == "" {
mime = "application/octet-stream"
}
attach := &OrgAttachment{
ID: attachID,
OrgID: org.ID,
RequestID: reqID,
UploadedBy: me.ID,
UploadedAt: time.Now(),
Filename: header.Filename,
MimeType: mime,
SizeBytes: size,
StoragePath: storagePath,
}
if err := h.store.createAttachment(ctx, attach); err != nil {
slog.Error("save attachment metadata", "err", err)
http.Error(w, "could not save attachment", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther)
})(w, r)
}
// ── Ledger ────────────────────────────────────────────────────────────────────
func (h *Handler) OrgLedger(w http.ResponseWriter, r *http.Request) {
@ -1807,6 +1874,7 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/action", h.OrgRequestAction)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/delivery", h.OrgRequestDelivery)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/settle", h.OrgRequestSettle)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/upload", h.OrgRequestUpload)
// Ledger (literal "import" before potential wildcards)
mux.HandleFunc("GET /orgs/{slug}/ledger", h.OrgLedger)

View File

@ -421,6 +421,9 @@ type OrgRequestDetailData struct {
FiscalYear *FiscalYear
Attachments []OrgAttachment
Error string
// populated on new-request GET
NewEvents []OrgEvent
NewTeams []OrgTeam
}
type OrgLedgerData struct {

View File

@ -43,10 +43,17 @@
{{dateShort $d.ActiveYear.StartDate}} — {{dateShort $d.ActiveYear.EndDate}}
</div>
</div>
<div style="display:flex; gap:8px;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/events" class="btn btn-outline btn-sm">Events</a>
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/requests" class="btn btn-outline btn-sm">Requests</a>
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/ledger" class="btn btn-outline btn-sm">Ledger</a>
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/analysis" class="btn btn-outline btn-sm">Analysis</a>
<a href="/orgs/{{$d.Org.Slug}}/requests" class="btn btn-outline btn-sm">Requests</a>
<a href="/orgs/{{$d.Org.Slug}}/ledger" class="btn btn-outline btn-sm">Ledger</a>
{{if eq $d.MyRole "admin"}}
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/close" style="display:inline;"
onsubmit="return confirm('Close {{$d.ActiveYear.Label}}? This cannot be undone. Teams will be able to add post-mortem feedback.')">
<button class="btn btn-sm" style="background:transparent; border:1px solid var(--text3); color:var(--text3);">Close year</button>
</form>
{{end}}
</div>
</div>
</div>

View File

@ -73,13 +73,18 @@
<label class="form-label">Event (optional)</label>
<select class="form-input" name="event_id">
<option value="">— none —</option>
{{if $d.FiscalYear}}{{end}}
{{range $d.NewEvents}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
<div>
<label class="form-label">Team (optional)</label>
<select class="form-input" name="team_id">
<option value="">— none —</option>
{{range $d.NewTeams}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
@ -224,17 +229,24 @@ window.showFields = showFields;
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:12px;">Attachments</h2>
{{if $d.Attachments}}
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:12px;">
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
{{range $d.Attachments}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px; background:var(--bg3); border-radius:var(--radius-sm);">
<span style="font-size:13px;">{{.Filename}}</span>
<span style="font-size:13px;">📎 {{.Filename}}</span>
<span style="font-size:11px; color:var(--text3);">{{.MimeType}}</span>
</div>
{{end}}
</div>
{{else}}
<p style="font-size:13px; color:var(--text3); margin-bottom:12px;">No attachments yet.</p>
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">No attachments yet.</p>
{{end}}
{{if ne (print $status) "reconciled"}}{{if ne (print $status) "cancelled"}}{{if ne (print $status) "rejected"}}
<form method="post" action="/orgs/{{$d.Org.Slug}}/requests/{{$req.ID}}/upload" enctype="multipart/form-data"
style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<input type="file" name="file" class="form-input" style="flex:1; min-width:0; font-size:12px;" required>
<button type="submit" class="btn btn-outline btn-sm" style="white-space:nowrap;">Upload</button>
</form>
{{end}}{{end}}{{end}}
</div>
<!-- Status log -->