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"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -1027,10 +1028,9 @@ func (h *Handler) OrgRequestNew(w http.ResponseWriter, r *http.Request) {
|
|||||||
Org: *org,
|
Org: *org,
|
||||||
MyRole: me.Role,
|
MyRole: me.Role,
|
||||||
FiscalYear: activeYear,
|
FiscalYear: activeYear,
|
||||||
// Use Request.ID="" to signal "new form" in template
|
NewEvents: events,
|
||||||
|
NewTeams: teams,
|
||||||
})
|
})
|
||||||
_ = events
|
|
||||||
_ = teams
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1387,6 +1387,73 @@ func (h *Handler) OrgRequestSettle(w http.ResponseWriter, r *http.Request) {
|
|||||||
})(w, r)
|
})(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 ────────────────────────────────────────────────────────────────────
|
// ── Ledger ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) OrgLedger(w http.ResponseWriter, r *http.Request) {
|
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}/action", h.OrgRequestAction)
|
||||||
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/delivery", h.OrgRequestDelivery)
|
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}/settle", h.OrgRequestSettle)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/upload", h.OrgRequestUpload)
|
||||||
|
|
||||||
// Ledger (literal "import" before potential wildcards)
|
// Ledger (literal "import" before potential wildcards)
|
||||||
mux.HandleFunc("GET /orgs/{slug}/ledger", h.OrgLedger)
|
mux.HandleFunc("GET /orgs/{slug}/ledger", h.OrgLedger)
|
||||||
|
|||||||
@ -421,6 +421,9 @@ type OrgRequestDetailData struct {
|
|||||||
FiscalYear *FiscalYear
|
FiscalYear *FiscalYear
|
||||||
Attachments []OrgAttachment
|
Attachments []OrgAttachment
|
||||||
Error string
|
Error string
|
||||||
|
// populated on new-request GET
|
||||||
|
NewEvents []OrgEvent
|
||||||
|
NewTeams []OrgTeam
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrgLedgerData struct {
|
type OrgLedgerData struct {
|
||||||
|
|||||||
@ -43,10 +43,17 @@
|
|||||||
{{dateShort $d.ActiveYear.StartDate}} — {{dateShort $d.ActiveYear.EndDate}}
|
{{dateShort $d.ActiveYear.StartDate}} — {{dateShort $d.ActiveYear.EndDate}}
|
||||||
</div>
|
</div>
|
||||||
</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}}/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}}/analysis" class="btn btn-outline btn-sm">Analysis</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}}/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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -73,13 +73,18 @@
|
|||||||
<label class="form-label">Event (optional)</label>
|
<label class="form-label">Event (optional)</label>
|
||||||
<select class="form-input" name="event_id">
|
<select class="form-input" name="event_id">
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
{{if $d.FiscalYear}}{{end}}
|
{{range $d.NewEvents}}
|
||||||
|
<option value="{{.ID}}">{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Team (optional)</label>
|
<label class="form-label">Team (optional)</label>
|
||||||
<select class="form-input" name="team_id">
|
<select class="form-input" name="team_id">
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
|
{{range $d.NewTeams}}
|
||||||
|
<option value="{{.ID}}">{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -224,17 +229,24 @@ window.showFields = showFields;
|
|||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:12px;">Attachments</h2>
|
<h2 style="margin-bottom:12px;">Attachments</h2>
|
||||||
{{if $d.Attachments}}
|
{{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}}
|
{{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);">
|
<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>
|
<span style="font-size:11px; color:var(--text3);">{{.MimeType}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{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}}
|
{{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>
|
</div>
|
||||||
|
|
||||||
<!-- Status log -->
|
<!-- Status log -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user