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:
parent
0f58a51c6d
commit
07e3525dae
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -43,10 +43,17 @@
|
||||
{{dateShort $d.ActiveYear.StartDate}} — {{dateShort $d.ActiveYear.EndDate}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<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>
|
||||
<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}}/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>
|
||||
|
||||
@ -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 -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user