feat(finance): org management scaffold — Phase 1
Adds multi-tenant organisation support inside the existing finance namespace.
Users can create organisations, invite others via a copy-paste token link,
and manage teams/members with RBAC (admin, finance, member, viewer).
Fiscal year lifecycle is gated: activation requires all planned events to
be approved first. All org data lives in `org_`-prefixed MongoDB collections.
New files:
- models_org.go — domain types (Org, OrgTeam, OrgMember, OrgInvite,
FiscalYear, OrgEvent, BudgetLine, EventComment,
TxRequest with full StatusLog audit trail, etc.)
- store_org.go — MongoDB store methods for all org collections
- handler_org.go — HTTP handlers + RegisterOrgRoutes(); join invite
route lives at /join/{token} to avoid ServeMux
conflict with /orgs/{slug}/... wildcard routes
- templates/org_*.html — list, create, home, teams, members, invite, join
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1b9284801c
commit
6ed848a001
@ -127,6 +127,15 @@ var (
|
|||||||
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
|
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
|
||||||
peopleTmpl = parseTmpl("templates/base.html", "templates/people.html")
|
peopleTmpl = parseTmpl("templates/base.html", "templates/people.html")
|
||||||
settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html")
|
settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html")
|
||||||
|
|
||||||
|
// Org
|
||||||
|
orgListTmpl = parseTmpl("templates/base.html", "templates/org_list.html")
|
||||||
|
orgCreateTmpl = parseTmpl("templates/base.html", "templates/org_create.html")
|
||||||
|
orgHomeTmpl = parseTmpl("templates/base.html", "templates/org_home.html")
|
||||||
|
orgTeamsTmpl = parseTmpl("templates/base.html", "templates/org_teams.html")
|
||||||
|
orgMembersTmpl = parseTmpl("templates/base.html", "templates/org_members.html")
|
||||||
|
orgInviteTmpl = parseTmpl("templates/base.html", "templates/org_invite.html")
|
||||||
|
orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
type authInfo struct {
|
type authInfo struct {
|
||||||
@ -194,6 +203,47 @@ type storeIface interface {
|
|||||||
getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error)
|
getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error)
|
||||||
createImportSchedule(ctx context.Context, sched *ImportSchedule) error
|
createImportSchedule(ctx context.Context, sched *ImportSchedule) error
|
||||||
deleteImportSchedule(ctx context.Context, id, userID string) error
|
deleteImportSchedule(ctx context.Context, id, userID string) error
|
||||||
|
|
||||||
|
// Org
|
||||||
|
getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error)
|
||||||
|
getOrg(ctx context.Context, orgID string) (*Org, error)
|
||||||
|
getOrgBySlug(ctx context.Context, slug string) (*Org, error)
|
||||||
|
createOrg(ctx context.Context, o *Org) error
|
||||||
|
slugExists(ctx context.Context, slug string) (bool, error)
|
||||||
|
getTeams(ctx context.Context, orgID string) ([]OrgTeam, error)
|
||||||
|
getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error)
|
||||||
|
createTeam(ctx context.Context, t *OrgTeam) error
|
||||||
|
deleteTeam(ctx context.Context, teamID, orgID string) error
|
||||||
|
getMembers(ctx context.Context, orgID string) ([]OrgMember, error)
|
||||||
|
getMember(ctx context.Context, orgID, userID string) (*OrgMember, error)
|
||||||
|
createMember(ctx context.Context, m *OrgMember) error
|
||||||
|
updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error
|
||||||
|
removeMember(ctx context.Context, memberID, orgID string) error
|
||||||
|
getInvites(ctx context.Context, orgID string) ([]OrgInvite, error)
|
||||||
|
getInviteByToken(ctx context.Context, token string) (*OrgInvite, error)
|
||||||
|
createInvite(ctx context.Context, inv *OrgInvite) error
|
||||||
|
consumeInvite(ctx context.Context, inviteID string) error
|
||||||
|
revokeInvite(ctx context.Context, inviteID, orgID string) error
|
||||||
|
getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error)
|
||||||
|
getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error)
|
||||||
|
getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error)
|
||||||
|
createFiscalYear(ctx context.Context, y *FiscalYear) error
|
||||||
|
updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error
|
||||||
|
getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error)
|
||||||
|
getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error)
|
||||||
|
createEvent(ctx context.Context, e *OrgEvent) error
|
||||||
|
updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error
|
||||||
|
deleteEvent(ctx context.Context, eventID, orgID string) error
|
||||||
|
getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error)
|
||||||
|
createBudgetLine(ctx context.Context, l *BudgetLine) error
|
||||||
|
deleteBudgetLine(ctx context.Context, lineID, orgID string) error
|
||||||
|
getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error)
|
||||||
|
createEventComment(ctx context.Context, c *EventComment) error
|
||||||
|
getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error)
|
||||||
|
getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error)
|
||||||
|
createTxRequest(ctx context.Context, r *TxRequest) error
|
||||||
|
appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error
|
||||||
|
updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@ -2488,6 +2538,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /tax", h.Tax)
|
mux.HandleFunc("GET /tax", h.Tax)
|
||||||
mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
|
mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
|
||||||
mux.HandleFunc("GET /auto-import", h.AutoImport)
|
mux.HandleFunc("GET /auto-import", h.AutoImport)
|
||||||
|
|
||||||
|
h.RegisterOrgRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortStrings(s []string) {
|
func sortStrings(s []string) {
|
||||||
|
|||||||
570
apps/finance/services/api/main/handler_org.go
Normal file
570
apps/finance/services/api/main/handler_org.go
Normal file
@ -0,0 +1,570 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// slugRe validates org slugs: lowercase letters, digits, hyphens.
|
||||||
|
var slugRe = regexp.MustCompile(`^[a-z0-9-]{2,40}$`)
|
||||||
|
|
||||||
|
// orgTmpl and friends are registered in handler.go parseTmpl block (below).
|
||||||
|
|
||||||
|
// ── Middleware helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// orgMW loads the org and the caller's membership. Injects them via context values
|
||||||
|
// wrapped in the request, or returns 403/404. next receives the same request.
|
||||||
|
//
|
||||||
|
// It sets two request-scoped values accessible via orgFromCtx / memberFromCtx:
|
||||||
|
// these are passed as function arguments here for simplicity (no context key needed).
|
||||||
|
func (h *Handler) requireOrgMember(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
|
||||||
|
return h.authMW(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
|
||||||
|
org, err := h.store.getOrgBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "organisation not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
me, err := h.store.getMember(ctx, org.ID, a.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "you are not a member of this organisation", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r, org, me)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) requireOrgRole(roles ...OrgRole) func(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
|
||||||
|
return func(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
|
||||||
|
return h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
for _, role := range roles {
|
||||||
|
if me.Role == role {
|
||||||
|
next(w, r, org, me)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "insufficient permissions", http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// canManageOrg is true for admin and finance.
|
||||||
|
func canManageOrg(role OrgRole) bool {
|
||||||
|
return role == OrgRoleAdmin || role == OrgRoleFinance
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org list & creation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
|
||||||
|
orgs, err := h.store.getOrgsForUser(ctx, a.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("get orgs for user", "err", err)
|
||||||
|
orgs = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, orgListTmpl, &OrgListData{
|
||||||
|
UserID: a.UserID,
|
||||||
|
Email: a.Email,
|
||||||
|
Title: "Organisations",
|
||||||
|
Route: "orgs",
|
||||||
|
Orgs: orgs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
render(w, orgCreateTmpl, map[string]any{
|
||||||
|
"Title": "New Organisation",
|
||||||
|
"Route": "orgs",
|
||||||
|
"UserID": a.UserID,
|
||||||
|
"Email": a.Email,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
slug := strings.TrimSpace(strings.ToLower(r.FormValue("slug")))
|
||||||
|
|
||||||
|
errMsg := ""
|
||||||
|
switch {
|
||||||
|
case name == "":
|
||||||
|
errMsg = "Name is required."
|
||||||
|
case !slugRe.MatchString(slug):
|
||||||
|
errMsg = "Slug must be 2–40 lowercase letters, digits or hyphens."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg == "" {
|
||||||
|
exists, _ := h.store.slugExists(ctx, slug)
|
||||||
|
if exists {
|
||||||
|
errMsg = "That slug is already taken — choose another."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
render(w, orgCreateTmpl, map[string]any{
|
||||||
|
"Title": "New Organisation",
|
||||||
|
"Route": "orgs",
|
||||||
|
"UserID": a.UserID,
|
||||||
|
"Email": a.Email,
|
||||||
|
"Error": errMsg,
|
||||||
|
"Name": name,
|
||||||
|
"Slug": slug,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
org := &Org{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
Name: name,
|
||||||
|
Slug: slug,
|
||||||
|
OwnerUserID: a.UserID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.store.createOrg(ctx, org); err != nil {
|
||||||
|
slog.Error("create org", "err", err)
|
||||||
|
http.Error(w, "could not create organisation", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// creator becomes admin
|
||||||
|
member := &OrgMember{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
OrgID: org.ID,
|
||||||
|
UserID: a.UserID,
|
||||||
|
Email: a.Email,
|
||||||
|
Role: OrgRoleAdmin,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.store.createMember(ctx, member); err != nil {
|
||||||
|
slog.Error("create founding member", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Org home ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) OrgHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
years, _ := h.store.getFiscalYears(ctx, org.ID)
|
||||||
|
teams, _ := h.store.getTeams(ctx, org.ID)
|
||||||
|
members, _ := h.store.getMembers(ctx, org.ID)
|
||||||
|
|
||||||
|
var active *FiscalYear
|
||||||
|
for i := range years {
|
||||||
|
if years[i].Status == FiscalYearActive {
|
||||||
|
active = &years[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, orgHomeTmpl, &OrgHomeData{
|
||||||
|
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||||
|
Email: r.Header.Get("X-Auth-Email"),
|
||||||
|
Title: org.Name,
|
||||||
|
Route: "orgs",
|
||||||
|
Org: *org,
|
||||||
|
MyRole: me.Role,
|
||||||
|
MyTeamIDs: me.TeamIDs,
|
||||||
|
FiscalYears: years,
|
||||||
|
ActiveYear: active,
|
||||||
|
Teams: teams,
|
||||||
|
Members: members,
|
||||||
|
})
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Teams ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) OrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
teams, _ := h.store.getTeams(ctx, org.ID)
|
||||||
|
members, _ := h.store.getMembers(ctx, org.ID)
|
||||||
|
|
||||||
|
render(w, orgTeamsTmpl, &OrgTeamsData{
|
||||||
|
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||||
|
Email: r.Header.Get("X-Auth-Email"),
|
||||||
|
Title: org.Name + " — Teams",
|
||||||
|
Route: "orgs",
|
||||||
|
Org: *org,
|
||||||
|
MyRole: me.Role,
|
||||||
|
Teams: teams,
|
||||||
|
Members: members,
|
||||||
|
})
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgTeamCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
teamType := TeamType(r.FormValue("type"))
|
||||||
|
if teamType != TeamTypeGuest {
|
||||||
|
teamType = TeamTypeInternal
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
team := &OrgTeam{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
OrgID: org.ID,
|
||||||
|
Name: name,
|
||||||
|
Type: teamType,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.store.createTeam(ctx, team); err != nil {
|
||||||
|
slog.Error("create team", "err", err)
|
||||||
|
http.Error(w, "could not create team", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug+"/teams", http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgTeamDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
teamID := r.PathValue("team_id")
|
||||||
|
if err := h.store.deleteTeam(r.Context(), teamID, org.ID); err != nil {
|
||||||
|
slog.Error("delete team", "err", err)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug+"/teams", http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) OrgMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
members, _ := h.store.getMembers(ctx, org.ID)
|
||||||
|
teams, _ := h.store.getTeams(ctx, org.ID)
|
||||||
|
invites, _ := h.store.getInvites(ctx, org.ID)
|
||||||
|
|
||||||
|
render(w, orgMembersTmpl, &OrgMembersData{
|
||||||
|
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||||
|
Email: r.Header.Get("X-Auth-Email"),
|
||||||
|
Title: org.Name + " — Members",
|
||||||
|
Route: "orgs",
|
||||||
|
Org: *org,
|
||||||
|
MyRole: me.Role,
|
||||||
|
Members: members,
|
||||||
|
Teams: teams,
|
||||||
|
Invites: invites,
|
||||||
|
})
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgMemberRoleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
memberID := r.PathValue("member_id")
|
||||||
|
role := OrgRole(r.FormValue("role"))
|
||||||
|
switch role {
|
||||||
|
case OrgRoleAdmin, OrgRoleFinance, OrgRoleMember, OrgRoleViewer:
|
||||||
|
default:
|
||||||
|
http.Error(w, "invalid role", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.updateMemberRole(ctx, memberID, org.ID, role); err != nil {
|
||||||
|
slog.Error("update member role", "err", err)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgMemberRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
memberID := r.PathValue("member_id")
|
||||||
|
// prevent removing yourself
|
||||||
|
a := getAuth(r)
|
||||||
|
if me.UserID == a.UserID && memberID == me.ID {
|
||||||
|
http.Error(w, "cannot remove yourself", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.removeMember(r.Context(), memberID, org.ID); err != nil {
|
||||||
|
slog.Error("remove member", "err", err)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invites ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) OrgInviteNew(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
teams, _ := h.store.getTeams(ctx, org.ID)
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
render(w, orgInviteTmpl, &OrgInviteData{
|
||||||
|
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||||
|
Email: r.Header.Get("X-Auth-Email"),
|
||||||
|
Title: "Invite to " + org.Name,
|
||||||
|
Route: "orgs",
|
||||||
|
Org: *org,
|
||||||
|
MyRole: me.Role,
|
||||||
|
Teams: teams,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||||
|
role := OrgRole(r.FormValue("role"))
|
||||||
|
teamIDs := r.Form["team_ids"]
|
||||||
|
|
||||||
|
errMsg := ""
|
||||||
|
switch {
|
||||||
|
case email == "":
|
||||||
|
errMsg = "Email is required."
|
||||||
|
case role != OrgRoleAdmin && role != OrgRoleFinance && role != OrgRoleMember && role != OrgRoleViewer:
|
||||||
|
errMsg = "Invalid role."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
render(w, orgInviteTmpl, &OrgInviteData{
|
||||||
|
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||||
|
Email: r.Header.Get("X-Auth-Email"),
|
||||||
|
Title: "Invite to " + org.Name,
|
||||||
|
Route: "orgs",
|
||||||
|
Org: *org,
|
||||||
|
MyRole: me.Role,
|
||||||
|
Teams: teams,
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := randomHex(32)
|
||||||
|
inv := &OrgInvite{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
OrgID: org.ID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
TeamIDs: teamIDs,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.store.createInvite(ctx, inv); err != nil {
|
||||||
|
slog.Error("create invite", "err", err)
|
||||||
|
http.Error(w, "could not create invite", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the link — email delivery is Phase 5
|
||||||
|
scheme := "https"
|
||||||
|
if r.TLS == nil {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
link := fmt.Sprintf("%s://%s/join/%s", scheme, r.Host, token)
|
||||||
|
|
||||||
|
render(w, orgInviteTmpl, &OrgInviteData{
|
||||||
|
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||||
|
Email: r.Header.Get("X-Auth-Email"),
|
||||||
|
Title: "Invite to " + org.Name,
|
||||||
|
Route: "orgs",
|
||||||
|
Org: *org,
|
||||||
|
MyRole: me.Role,
|
||||||
|
Teams: teams,
|
||||||
|
Link: link,
|
||||||
|
})
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgInviteRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
inviteID := r.PathValue("invite_id")
|
||||||
|
if err := h.store.revokeInvite(r.Context(), inviteID, org.ID); err != nil {
|
||||||
|
slog.Error("revoke invite", "err", err)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgJoin handles the invite link: GET shows a confirmation page, POST accepts.
|
||||||
|
func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
token := r.PathValue("token")
|
||||||
|
|
||||||
|
inv, err := h.store.getInviteByToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invite not found or expired", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
org, err := h.store.getOrg(ctx, inv.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "organisation not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
render(w, orgJoinTmpl, map[string]any{
|
||||||
|
"Title": "Join " + org.Name,
|
||||||
|
"Route": "orgs",
|
||||||
|
"UserID": a.UserID,
|
||||||
|
"Email": a.Email,
|
||||||
|
"Org": org,
|
||||||
|
"Invite": inv,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// check already a member
|
||||||
|
if _, err := h.store.getMember(ctx, org.ID, a.UserID); err == nil {
|
||||||
|
// already in — just consume the invite and redirect
|
||||||
|
_ = h.store.consumeInvite(ctx, inv.ID)
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
member := &OrgMember{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
OrgID: org.ID,
|
||||||
|
UserID: a.UserID,
|
||||||
|
Email: a.Email,
|
||||||
|
Role: inv.Role,
|
||||||
|
TeamIDs: inv.TeamIDs,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.store.createMember(ctx, member); err != nil {
|
||||||
|
slog.Error("create member from invite", "err", err)
|
||||||
|
http.Error(w, "could not join organisation", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.consumeInvite(ctx, inv.ID); err != nil {
|
||||||
|
slog.Error("consume invite", "err", err)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fiscal Years ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) OrgFiscalYearCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
label := strings.TrimSpace(r.FormValue("label"))
|
||||||
|
startStr := r.FormValue("start_date")
|
||||||
|
endStr := r.FormValue("end_date")
|
||||||
|
|
||||||
|
start, errS := time.Parse("2006-01-02", startStr)
|
||||||
|
end, errE := time.Parse("2006-01-02", endStr)
|
||||||
|
if label == "" || errS != nil || errE != nil || !end.After(start) {
|
||||||
|
http.Error(w, "invalid fiscal year data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
y := &FiscalYear{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
OrgID: org.ID,
|
||||||
|
Label: label,
|
||||||
|
Status: FiscalYearDraft,
|
||||||
|
StartDate: start,
|
||||||
|
EndDate: end,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.store.createFiscalYear(ctx, y); err != nil {
|
||||||
|
slog.Error("create fiscal year", "err", err)
|
||||||
|
http.Error(w, "could not create fiscal year", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OrgFiscalYearActivate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||||
|
ctx := r.Context()
|
||||||
|
yearID := r.PathValue("year_id")
|
||||||
|
|
||||||
|
// verify no other year is already active
|
||||||
|
if active, _ := h.store.getActiveFiscalYear(ctx, org.ID); active != nil {
|
||||||
|
http.Error(w, "another fiscal year is already active — close it first", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify all events in this year are approved
|
||||||
|
events, err := h.store.getEvents(ctx, org.ID, yearID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not load events", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Status != EventApproved {
|
||||||
|
http.Error(w, fmt.Sprintf("event %q is not yet approved", e.Name), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.updateFiscalYearStatus(ctx, yearID, org.ID, FiscalYearActive, bson.M{
|
||||||
|
"started_at": time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("activate fiscal year", "err", err)
|
||||||
|
http.Error(w, "could not activate fiscal year", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
|
||||||
|
// Exact/literal patterns first (must precede wildcard {slug} routes)
|
||||||
|
mux.HandleFunc("GET /orgs", h.authMW(h.OrgList))
|
||||||
|
mux.HandleFunc("GET /orgs/new", h.authMW(h.OrgCreate))
|
||||||
|
mux.HandleFunc("POST /orgs/new", h.authMW(h.OrgCreate))
|
||||||
|
mux.HandleFunc("GET /join/{token}", h.authMW(h.OrgJoin))
|
||||||
|
mux.HandleFunc("POST /join/{token}", h.authMW(h.OrgJoin))
|
||||||
|
|
||||||
|
// {slug} wildcard routes
|
||||||
|
mux.HandleFunc("GET /orgs/{slug}", h.OrgHome)
|
||||||
|
|
||||||
|
// Teams
|
||||||
|
mux.HandleFunc("GET /orgs/{slug}/teams", h.OrgTeams)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/teams", h.OrgTeamCreate)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/teams/{team_id}/delete", h.OrgTeamDelete)
|
||||||
|
|
||||||
|
// Members
|
||||||
|
mux.HandleFunc("GET /orgs/{slug}/members", h.OrgMembers)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/members/{member_id}/role", h.OrgMemberRoleUpdate)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/members/{member_id}/remove", h.OrgMemberRemove)
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
mux.HandleFunc("GET /orgs/{slug}/invite", h.OrgInviteNew)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/invite", h.OrgInviteNew)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/invites/{invite_id}/revoke", h.OrgInviteRevoke)
|
||||||
|
|
||||||
|
// Fiscal years
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/years", h.OrgFiscalYearCreate)
|
||||||
|
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/activate", h.OrgFiscalYearActivate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@ -170,6 +170,54 @@ func (m *mockStore) getImportSchedules(_ context.Context, _ string) ([]ImportSch
|
|||||||
func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil }
|
func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil }
|
||||||
func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil }
|
func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil }
|
||||||
|
|
||||||
|
// ── Org stubs (not exercised in unit tests) ───────────────────────────────────
|
||||||
|
|
||||||
|
func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) getOrg(_ context.Context, _ string) (*Org, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getOrgBySlug(_ context.Context, _ string) (*Org, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createOrg(_ context.Context, _ *Org) error { return nil }
|
||||||
|
func (m *mockStore) slugExists(_ context.Context, _ string) (bool, error) { return false, nil }
|
||||||
|
func (m *mockStore) getTeams(_ context.Context, _ string) ([]OrgTeam, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getTeam(_ context.Context, _, _ string) (*OrgTeam, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createTeam(_ context.Context, _ *OrgTeam) error { return nil }
|
||||||
|
func (m *mockStore) deleteTeam(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (m *mockStore) getMembers(_ context.Context, _ string) ([]OrgMember, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getMember(_ context.Context, _, _ string) (*OrgMember, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) createMember(_ context.Context, _ *OrgMember) error { return nil }
|
||||||
|
func (m *mockStore) updateMemberRole(_ context.Context, _, _ string, _ OrgRole) error { return nil }
|
||||||
|
func (m *mockStore) removeMember(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (m *mockStore) getInvites(_ context.Context, _ string) ([]OrgInvite, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getInviteByToken(_ context.Context, _ string) (*OrgInvite, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createInvite(_ context.Context, _ *OrgInvite) error { return nil }
|
||||||
|
func (m *mockStore) consumeInvite(_ context.Context, _ string) error { return nil }
|
||||||
|
func (m *mockStore) revokeInvite(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getFiscalYear(_ context.Context, _, _ string) (*FiscalYear, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createFiscalYear(_ context.Context, _ *FiscalYear) error { return nil }
|
||||||
|
func (m *mockStore) updateFiscalYearStatus(_ context.Context, _, _ string, _ FiscalYearStatus, _ bson.M) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil }
|
||||||
|
func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||||
|
func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (m *mockStore) getBudgetLines(_ context.Context, _, _ string) ([]BudgetLine, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createBudgetLine(_ context.Context, _ *BudgetLine) error { return nil }
|
||||||
|
func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (m *mockStore) getEventComments(_ context.Context, _, _ string) ([]EventComment, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createEventComment(_ context.Context, _ *EventComment) error { return nil }
|
||||||
|
func (m *mockStore) getTxRequests(_ context.Context, _ string, _ bson.M) ([]TxRequest, error) { return nil, nil }
|
||||||
|
func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, error) { return nil, nil }
|
||||||
|
func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil }
|
||||||
|
func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil }
|
||||||
|
func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func newHandler(store *mockStore) *Handler {
|
func newHandler(store *mockStore) *Handler {
|
||||||
|
|||||||
356
apps/finance/services/api/main/models_org.go
Normal file
356
apps/finance/services/api/main/models_org.go
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ── Org RBAC ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrgRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrgRoleAdmin OrgRole = "admin"
|
||||||
|
OrgRoleFinance OrgRole = "finance"
|
||||||
|
OrgRoleMember OrgRole = "member"
|
||||||
|
OrgRoleViewer OrgRole = "viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TeamType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TeamTypeInternal TeamType = "internal"
|
||||||
|
TeamTypeGuest TeamType = "guest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Core entities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Org struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
Name string `bson:"name" json:"name"`
|
||||||
|
Slug string `bson:"slug" json:"slug"`
|
||||||
|
OwnerUserID string `bson:"owner_user_id" json:"owner_user_id"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgTeam struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
Name string `bson:"name" json:"name"`
|
||||||
|
Type TeamType `bson:"type" json:"type"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgMember links a user to an org with a role and optional team subset.
|
||||||
|
// All financial approvals go through org-level finance/admin regardless of team.
|
||||||
|
// Guest team members are scoped to their own team's data only (visibility scope,
|
||||||
|
// not approval scope).
|
||||||
|
type OrgMember struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
UserID string `bson:"user_id" json:"user_id"`
|
||||||
|
Email string `bson:"email" json:"email"`
|
||||||
|
Role OrgRole `bson:"role" json:"role"`
|
||||||
|
TeamIDs []string `bson:"team_ids" json:"team_ids"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgInvite is a pending invitation. Token is a random hex string.
|
||||||
|
// Email delivery is a TODO (Phase 5) — for now the link is displayed to the inviter.
|
||||||
|
type OrgInvite struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
Email string `bson:"email" json:"email"`
|
||||||
|
Role OrgRole `bson:"role" json:"role"`
|
||||||
|
TeamIDs []string `bson:"team_ids" json:"team_ids"`
|
||||||
|
Token string `bson:"token" json:"token"`
|
||||||
|
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
UsedAt time.Time `bson:"used_at,omitempty" json:"used_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fiscal year ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FiscalYearStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FiscalYearDraft FiscalYearStatus = "draft"
|
||||||
|
FiscalYearActive FiscalYearStatus = "active"
|
||||||
|
FiscalYearClosed FiscalYearStatus = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FiscalYear struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
Label string `bson:"label" json:"label"` // e.g. "2025"
|
||||||
|
Status FiscalYearStatus `bson:"status" json:"status"`
|
||||||
|
StartDate time.Time `bson:"start_date" json:"start_date"`
|
||||||
|
EndDate time.Time `bson:"end_date" json:"end_date"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||||
|
ClosedAt time.Time `bson:"closed_at,omitempty" json:"closed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Planning: Events & Budget ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type EventStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventDraft EventStatus = "draft"
|
||||||
|
EventReview EventStatus = "review"
|
||||||
|
EventApproved EventStatus = "approved"
|
||||||
|
EventRejected EventStatus = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrgEvent struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"`
|
||||||
|
TeamIDs []string `bson:"team_ids" json:"team_ids"`
|
||||||
|
Name string `bson:"name" json:"name"`
|
||||||
|
Description string `bson:"description" json:"description"`
|
||||||
|
Goals string `bson:"goals" json:"goals"`
|
||||||
|
DateStart time.Time `bson:"date_start" json:"date_start"`
|
||||||
|
DateEnd time.Time `bson:"date_end" json:"date_end"`
|
||||||
|
Status EventStatus `bson:"status" json:"status"`
|
||||||
|
CreatedBy string `bson:"created_by" json:"created_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetLineType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BudgetIncome BudgetLineType = "income"
|
||||||
|
BudgetExpense BudgetLineType = "expense"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BudgetLine struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
EventID string `bson:"event_id" json:"event_id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
Category string `bson:"category" json:"category"`
|
||||||
|
Type BudgetLineType `bson:"type" json:"type"`
|
||||||
|
PlannedCents int64 `bson:"planned_cents" json:"planned_cents"`
|
||||||
|
Description string `bson:"description" json:"description"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventComment is used for two purposes distinguished by Kind:
|
||||||
|
// - "review" — admin → team during planning (request changes / feedback)
|
||||||
|
// - "feedback" — team post-mortem after year closes (included in year-end report)
|
||||||
|
type EventCommentKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommentReview EventCommentKind = "review"
|
||||||
|
CommentFeedback EventCommentKind = "feedback"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventComment struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
EventID string `bson:"event_id" json:"event_id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
UserID string `bson:"user_id" json:"user_id"`
|
||||||
|
UserEmail string `bson:"user_email" json:"user_email"`
|
||||||
|
Kind EventCommentKind `bson:"kind" json:"kind"`
|
||||||
|
Body string `bson:"body" json:"body"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execution: Transaction Requests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
type TxRequestType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TxReimbursement TxRequestType = "reimbursement"
|
||||||
|
TxPurchaseOrder TxRequestType = "purchase_order"
|
||||||
|
TxCashAdvance TxRequestType = "cash_advance"
|
||||||
|
TxIncome TxRequestType = "income"
|
||||||
|
TxBudgetTransfer TxRequestType = "budget_transfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TxRequestStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TxDraft TxRequestStatus = "draft"
|
||||||
|
TxSubmitted TxRequestStatus = "submitted"
|
||||||
|
TxInfoRequested TxRequestStatus = "info_requested"
|
||||||
|
TxUnderReview TxRequestStatus = "under_review"
|
||||||
|
TxApproved TxRequestStatus = "approved"
|
||||||
|
TxRejected TxRequestStatus = "rejected"
|
||||||
|
TxCancelled TxRequestStatus = "cancelled"
|
||||||
|
// Reimbursement
|
||||||
|
TxPaid TxRequestStatus = "paid"
|
||||||
|
// Purchase Order
|
||||||
|
TxOrdered TxRequestStatus = "ordered"
|
||||||
|
TxDelivered TxRequestStatus = "delivered"
|
||||||
|
TxDisputed TxRequestStatus = "disputed"
|
||||||
|
// Cash Advance
|
||||||
|
TxDisbursed TxRequestStatus = "disbursed"
|
||||||
|
TxSettlementDue TxRequestStatus = "settlement_due"
|
||||||
|
TxSettled TxRequestStatus = "settled"
|
||||||
|
TxPartialSettlement TxRequestStatus = "partial_settlement"
|
||||||
|
// Income
|
||||||
|
TxPendingPayment TxRequestStatus = "pending_payment"
|
||||||
|
TxReceived TxRequestStatus = "received"
|
||||||
|
// Shared terminal
|
||||||
|
TxReconciled TxRequestStatus = "reconciled"
|
||||||
|
// Budget transfer terminal
|
||||||
|
TxDone TxRequestStatus = "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusLogEntry is appended on every status change. Never mutated.
|
||||||
|
// When status = info_requested, Comment is required.
|
||||||
|
type StatusLogEntry struct {
|
||||||
|
Status TxRequestStatus `bson:"status" json:"status"`
|
||||||
|
ChangedBy string `bson:"changed_by" json:"changed_by"`
|
||||||
|
ChangedAt time.Time `bson:"changed_at" json:"changed_at"`
|
||||||
|
Comment string `bson:"comment,omitempty" json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PODelivery is filled in by the requester when a Purchase Order arrives.
|
||||||
|
type PODelivery struct {
|
||||||
|
ActualAmountCents int64 `bson:"actual_amount_cents" json:"actual_amount_cents"`
|
||||||
|
ActualVendor string `bson:"actual_vendor" json:"actual_vendor"`
|
||||||
|
DeliveredAt time.Time `bson:"delivered_at" json:"delivered_at"`
|
||||||
|
InvoiceAttachmentID []string `bson:"invoice_attachment_ids" json:"invoice_attachment_ids"`
|
||||||
|
StoreChanged bool `bson:"store_changed" json:"store_changed"`
|
||||||
|
ChangeNote string `bson:"change_note,omitempty" json:"change_note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CashSettlement is filled in by the requester when settling a Cash Advance.
|
||||||
|
type CashSettlement struct {
|
||||||
|
AmountSpentCents int64 `bson:"amount_spent_cents" json:"amount_spent_cents"`
|
||||||
|
AmountReturnedCents int64 `bson:"amount_returned_cents" json:"amount_returned_cents"`
|
||||||
|
ReceiptAttachmentIDs []string `bson:"receipt_attachment_ids" json:"receipt_attachment_ids"`
|
||||||
|
SettledAt time.Time `bson:"settled_at" json:"settled_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TxRequest struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"`
|
||||||
|
EventID string `bson:"event_id" json:"event_id"`
|
||||||
|
BudgetLineID string `bson:"budget_line_id" json:"budget_line_id"`
|
||||||
|
TeamID string `bson:"team_id" json:"team_id"`
|
||||||
|
SubmittedBy string `bson:"submitted_by" json:"submitted_by"`
|
||||||
|
SubmitterEmail string `bson:"submitter_email" json:"submitter_email"`
|
||||||
|
Type TxRequestType `bson:"type" json:"type"`
|
||||||
|
Description string `bson:"description" json:"description"`
|
||||||
|
AmountCents int64 `bson:"amount_cents" json:"amount_cents"`
|
||||||
|
Vendor string `bson:"vendor,omitempty" json:"vendor,omitempty"`
|
||||||
|
PayerName string `bson:"payer_name,omitempty" json:"payer_name,omitempty"`
|
||||||
|
DueDate time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
|
||||||
|
PaymentMethod string `bson:"payment_method,omitempty" json:"payment_method,omitempty"`
|
||||||
|
// PO-specific
|
||||||
|
Delivery *PODelivery `bson:"delivery,omitempty" json:"delivery,omitempty"`
|
||||||
|
// Cash advance specific
|
||||||
|
Settlement *CashSettlement `bson:"settlement,omitempty" json:"settlement,omitempty"`
|
||||||
|
// Budget transfer specific
|
||||||
|
FromBudgetLineID string `bson:"from_budget_line_id,omitempty" json:"from_budget_line_id,omitempty"`
|
||||||
|
ToBudgetLineID string `bson:"to_budget_line_id,omitempty" json:"to_budget_line_id,omitempty"`
|
||||||
|
|
||||||
|
AttachmentIDs []string `bson:"attachment_ids" json:"attachment_ids"`
|
||||||
|
StatusLog []StatusLogEntry `bson:"status_log" json:"status_log"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentStatus returns the latest status from the log.
|
||||||
|
func (r *TxRequest) CurrentStatus() TxRequestStatus {
|
||||||
|
if len(r.StatusLog) == 0 {
|
||||||
|
return TxDraft
|
||||||
|
}
|
||||||
|
return r.StatusLog[len(r.StatusLog)-1].Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgAttachment stores file metadata; the file lives on disk.
|
||||||
|
type OrgAttachment struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
RequestID string `bson:"request_id" json:"request_id"`
|
||||||
|
UploadedBy string `bson:"uploaded_by" json:"uploaded_by"`
|
||||||
|
UploadedAt time.Time `bson:"uploaded_at" json:"uploaded_at"`
|
||||||
|
Filename string `bson:"filename" json:"filename"`
|
||||||
|
MimeType string `bson:"mime_type" json:"mime_type"`
|
||||||
|
SizeBytes int64 `bson:"size_bytes" json:"size_bytes"`
|
||||||
|
// Path on disk: /data/org-files/{org_id}/{request_id}/{id}
|
||||||
|
StoragePath string `bson:"storage_path" json:"storage_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgLedgerEntry is created from an approved TxRequest.
|
||||||
|
// bank_ref is set when the entry is matched to a bank CSV row (reconciliation).
|
||||||
|
type OrgLedgerEntry struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
OrgID string `bson:"org_id" json:"org_id"`
|
||||||
|
FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"`
|
||||||
|
EventID string `bson:"event_id" json:"event_id"`
|
||||||
|
BudgetLineID string `bson:"budget_line_id" json:"budget_line_id"`
|
||||||
|
TeamID string `bson:"team_id" json:"team_id"`
|
||||||
|
RequestID string `bson:"request_id,omitempty" json:"request_id,omitempty"`
|
||||||
|
AmountCents int64 `bson:"amount_cents" json:"amount_cents"`
|
||||||
|
Description string `bson:"description" json:"description"`
|
||||||
|
Date time.Time `bson:"date" json:"date"`
|
||||||
|
BankRef string `bson:"bank_ref,omitempty" json:"bank_ref,omitempty"`
|
||||||
|
Reconciled bool `bson:"reconciled" json:"reconciled"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page data structs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrgListData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Orgs []OrgWithRole
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgWithRole struct {
|
||||||
|
Org Org
|
||||||
|
Role OrgRole
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgHomeData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
MyTeamIDs []string
|
||||||
|
FiscalYears []FiscalYear
|
||||||
|
ActiveYear *FiscalYear
|
||||||
|
Teams []OrgTeam
|
||||||
|
Members []OrgMember
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgTeamsData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
Teams []OrgTeam
|
||||||
|
Members []OrgMember // for showing team membership counts
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgMembersData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
Members []OrgMember
|
||||||
|
Teams []OrgTeam
|
||||||
|
Invites []OrgInvite
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgInviteData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
Teams []OrgTeam
|
||||||
|
Error string
|
||||||
|
Link string // generated invite link shown after creation
|
||||||
|
}
|
||||||
529
apps/finance/services/api/main/store_org.go
Normal file
529
apps/finance/services/api/main/store_org.go
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homelab/pkg/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
|
mgmongo "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Collection helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) orgs() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_organizations")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) orgTeams() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_teams")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) orgMembers() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) orgInvites() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_invites")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) fiscalYears() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_fiscal_years")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) orgEvents() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) budgetLines() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_budget_lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) eventComments() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_event_comments")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) txRequests() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_tx_requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) orgLedger() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_ledger")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) orgAttachments() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("org_attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Organizations ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getOrgsForUser")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
cur, err := s.orgMembers().Find(ctx, bson.M{"user_id": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find memberships: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
|
||||||
|
var members []OrgMember
|
||||||
|
if err := cur.All(ctx, &members); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode memberships: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orgIDs := make([]string, len(members))
|
||||||
|
roleByOrg := make(map[string]OrgRole, len(members))
|
||||||
|
for i, m := range members {
|
||||||
|
orgIDs[i] = m.OrgID
|
||||||
|
roleByOrg[m.OrgID] = m.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
cur2, err := s.orgs().Find(ctx, bson.M{"_id": bson.M{"$in": orgIDs}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find orgs: %w", err)
|
||||||
|
}
|
||||||
|
defer cur2.Close(ctx)
|
||||||
|
|
||||||
|
var orgs []Org
|
||||||
|
if err := cur2.All(ctx, &orgs); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode orgs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]OrgWithRole, len(orgs))
|
||||||
|
for i, o := range orgs {
|
||||||
|
result[i] = OrgWithRole{Org: o, Role: roleByOrg[o.ID]}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getOrg(ctx context.Context, orgID string) (*Org, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getOrg")
|
||||||
|
defer span.End()
|
||||||
|
var o Org
|
||||||
|
if err := s.orgs().FindOne(ctx, bson.M{"_id": orgID}).Decode(&o); err != nil {
|
||||||
|
return nil, fmt.Errorf("find org: %w", err)
|
||||||
|
}
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getOrgBySlug(ctx context.Context, slug string) (*Org, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getOrgBySlug")
|
||||||
|
defer span.End()
|
||||||
|
var o Org
|
||||||
|
if err := s.orgs().FindOne(ctx, bson.M{"slug": slug}).Decode(&o); err != nil {
|
||||||
|
return nil, fmt.Errorf("find org by slug: %w", err)
|
||||||
|
}
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createOrg(ctx context.Context, o *Org) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createOrg")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgs().InsertOne(ctx, o)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) slugExists(ctx context.Context, slug string) (bool, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.slugExists")
|
||||||
|
defer span.End()
|
||||||
|
n, err := s.orgs().CountDocuments(ctx, bson.M{"slug": slug})
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Teams ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getTeams(ctx context.Context, orgID string) ([]OrgTeam, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getTeams")
|
||||||
|
defer span.End()
|
||||||
|
opts := options.Find().SetSort(bson.M{"name": 1})
|
||||||
|
cur, err := s.orgTeams().Find(ctx, bson.M{"org_id": orgID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find teams: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var teams []OrgTeam
|
||||||
|
if err := cur.All(ctx, &teams); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode teams: %w", err)
|
||||||
|
}
|
||||||
|
return teams, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getTeam")
|
||||||
|
defer span.End()
|
||||||
|
var t OrgTeam
|
||||||
|
if err := s.orgTeams().FindOne(ctx, bson.M{"_id": teamID, "org_id": orgID}).Decode(&t); err != nil {
|
||||||
|
return nil, fmt.Errorf("find team: %w", err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createTeam(ctx context.Context, t *OrgTeam) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createTeam")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgTeams().InsertOne(ctx, t)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) deleteTeam(ctx context.Context, teamID, orgID string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.deleteTeam")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgTeams().DeleteOne(ctx, bson.M{"_id": teamID, "org_id": orgID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getMembers(ctx context.Context, orgID string) ([]OrgMember, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getMembers")
|
||||||
|
defer span.End()
|
||||||
|
opts := options.Find().SetSort(bson.M{"email": 1})
|
||||||
|
cur, err := s.orgMembers().Find(ctx, bson.M{"org_id": orgID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find members: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var members []OrgMember
|
||||||
|
if err := cur.All(ctx, &members); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode members: %w", err)
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getMember(ctx context.Context, orgID, userID string) (*OrgMember, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getMember")
|
||||||
|
defer span.End()
|
||||||
|
var m OrgMember
|
||||||
|
if err := s.orgMembers().FindOne(ctx, bson.M{"org_id": orgID, "user_id": userID}).Decode(&m); err != nil {
|
||||||
|
return nil, fmt.Errorf("find member: %w", err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createMember(ctx context.Context, m *OrgMember) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createMember")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgMembers().InsertOne(ctx, m)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.updateMemberRole")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgMembers().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": memberID, "org_id": orgID},
|
||||||
|
bson.M{"$set": bson.M{"role": role}},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) removeMember(ctx context.Context, memberID, orgID string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.removeMember")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgMembers().DeleteOne(ctx, bson.M{"_id": memberID, "org_id": orgID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invites ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getInvites(ctx context.Context, orgID string) ([]OrgInvite, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getInvites")
|
||||||
|
defer span.End()
|
||||||
|
// only pending (not yet used, not expired)
|
||||||
|
cur, err := s.orgInvites().Find(ctx, bson.M{
|
||||||
|
"org_id": orgID,
|
||||||
|
"expires_at": bson.M{"$gt": time.Now()},
|
||||||
|
"used_at": bson.M{"$exists": false},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find invites: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var invites []OrgInvite
|
||||||
|
if err := cur.All(ctx, &invites); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode invites: %w", err)
|
||||||
|
}
|
||||||
|
return invites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getInviteByToken(ctx context.Context, token string) (*OrgInvite, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getInviteByToken")
|
||||||
|
defer span.End()
|
||||||
|
var inv OrgInvite
|
||||||
|
err := s.orgInvites().FindOne(ctx, bson.M{
|
||||||
|
"token": token,
|
||||||
|
"expires_at": bson.M{"$gt": time.Now()},
|
||||||
|
"used_at": bson.M{"$exists": false},
|
||||||
|
}).Decode(&inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find invite: %w", err)
|
||||||
|
}
|
||||||
|
return &inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createInvite(ctx context.Context, inv *OrgInvite) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createInvite")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgInvites().InsertOne(ctx, inv)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) consumeInvite(ctx context.Context, inviteID string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.consumeInvite")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgInvites().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": inviteID},
|
||||||
|
bson.M{"$set": bson.M{"used_at": time.Now()}},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) revokeInvite(ctx context.Context, inviteID, orgID string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.revokeInvite")
|
||||||
|
defer span.End()
|
||||||
|
// set expiry to past
|
||||||
|
_, err := s.orgInvites().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": inviteID, "org_id": orgID},
|
||||||
|
bson.M{"$set": bson.M{"expires_at": time.Now().Add(-time.Second)}},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fiscal Years ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getFiscalYears")
|
||||||
|
defer span.End()
|
||||||
|
opts := options.Find().SetSort(bson.M{"start_date": -1})
|
||||||
|
cur, err := s.fiscalYears().Find(ctx, bson.M{"org_id": orgID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find fiscal years: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var years []FiscalYear
|
||||||
|
if err := cur.All(ctx, &years); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode fiscal years: %w", err)
|
||||||
|
}
|
||||||
|
return years, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getFiscalYear")
|
||||||
|
defer span.End()
|
||||||
|
var y FiscalYear
|
||||||
|
if err := s.fiscalYears().FindOne(ctx, bson.M{"_id": yearID, "org_id": orgID}).Decode(&y); err != nil {
|
||||||
|
return nil, fmt.Errorf("find fiscal year: %w", err)
|
||||||
|
}
|
||||||
|
return &y, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getActiveFiscalYear")
|
||||||
|
defer span.End()
|
||||||
|
var y FiscalYear
|
||||||
|
err := s.fiscalYears().FindOne(ctx, bson.M{"org_id": orgID, "status": FiscalYearActive}).Decode(&y)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &y, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createFiscalYear(ctx context.Context, y *FiscalYear) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createFiscalYear")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.fiscalYears().InsertOne(ctx, y)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.updateFiscalYearStatus")
|
||||||
|
defer span.End()
|
||||||
|
set := bson.M{"status": status}
|
||||||
|
for k, v := range extraSet {
|
||||||
|
set[k] = v
|
||||||
|
}
|
||||||
|
_, err := s.fiscalYears().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": yearID, "org_id": orgID},
|
||||||
|
bson.M{"$set": set},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getEvents")
|
||||||
|
defer span.End()
|
||||||
|
q := bson.M{"org_id": orgID}
|
||||||
|
if fiscalYearID != "" {
|
||||||
|
q["fiscal_year_id"] = fiscalYearID
|
||||||
|
}
|
||||||
|
opts := options.Find().SetSort(bson.M{"date_start": 1})
|
||||||
|
cur, err := s.orgEvents().Find(ctx, q, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find events: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var events []OrgEvent
|
||||||
|
if err := cur.All(ctx, &events); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode events: %w", err)
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getEvent")
|
||||||
|
defer span.End()
|
||||||
|
var e OrgEvent
|
||||||
|
if err := s.orgEvents().FindOne(ctx, bson.M{"_id": eventID, "org_id": orgID}).Decode(&e); err != nil {
|
||||||
|
return nil, fmt.Errorf("find event: %w", err)
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createEvent(ctx context.Context, e *OrgEvent) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createEvent")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgEvents().InsertOne(ctx, e)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.updateEvent")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgEvents().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": eventID, "org_id": orgID},
|
||||||
|
bson.M{"$set": update},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) deleteEvent(ctx context.Context, eventID, orgID string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.deleteEvent")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgEvents().DeleteOne(ctx, bson.M{"_id": eventID, "org_id": orgID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget Lines ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getBudgetLines")
|
||||||
|
defer span.End()
|
||||||
|
cur, err := s.budgetLines().Find(ctx, bson.M{"event_id": eventID, "org_id": orgID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find budget lines: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var lines []BudgetLine
|
||||||
|
if err := cur.All(ctx, &lines); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode budget lines: %w", err)
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createBudgetLine(ctx context.Context, l *BudgetLine) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createBudgetLine")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.budgetLines().InsertOne(ctx, l)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) deleteBudgetLine(ctx context.Context, lineID, orgID string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.deleteBudgetLine")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.budgetLines().DeleteOne(ctx, bson.M{"_id": lineID, "org_id": orgID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event Comments ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getEventComments")
|
||||||
|
defer span.End()
|
||||||
|
opts := options.Find().SetSort(bson.M{"created_at": 1})
|
||||||
|
cur, err := s.eventComments().Find(ctx, bson.M{"event_id": eventID, "org_id": orgID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find event comments: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var comments []EventComment
|
||||||
|
if err := cur.All(ctx, &comments); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode event comments: %w", err)
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createEventComment(ctx context.Context, c *EventComment) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createEventComment")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.eventComments().InsertOne(ctx, c)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transaction Requests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getTxRequests")
|
||||||
|
defer span.End()
|
||||||
|
q := bson.M{"org_id": orgID}
|
||||||
|
for k, v := range filter {
|
||||||
|
q[k] = v
|
||||||
|
}
|
||||||
|
opts := options.Find().SetSort(bson.M{"created_at": -1})
|
||||||
|
cur, err := s.txRequests().Find(ctx, q, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find tx requests: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var reqs []TxRequest
|
||||||
|
if err := cur.All(ctx, &reqs); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode tx requests: %w", err)
|
||||||
|
}
|
||||||
|
return reqs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getTxRequest")
|
||||||
|
defer span.End()
|
||||||
|
var r TxRequest
|
||||||
|
if err := s.txRequests().FindOne(ctx, bson.M{"_id": reqID, "org_id": orgID}).Decode(&r); err != nil {
|
||||||
|
return nil, fmt.Errorf("find tx request: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createTxRequest(ctx context.Context, r *TxRequest) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createTxRequest")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.txRequests().InsertOne(ctx, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.appendStatusLog")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.txRequests().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": reqID, "org_id": orgID},
|
||||||
|
bson.M{"$push": bson.M{"status_log": entry}},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.updateTxRequest")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.txRequests().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": reqID, "org_id": orgID},
|
||||||
|
bson.M{"$set": update},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -504,6 +504,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
||||||
|
<a href="/orgs" class="{{if eq .Route "orgs"}}active{{end}}">Organisations</a>
|
||||||
|
|
||||||
{{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}
|
{{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
@ -531,6 +532,7 @@
|
|||||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
||||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
||||||
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
||||||
|
<a href="/orgs" class="{{if eq .Route "orgs"}}active{{end}}">Organisations</a>
|
||||||
<hr>
|
<hr>
|
||||||
<span class="nav-drawer-section-label">Analysis</span>
|
<span class="nav-drawer-section-label">Analysis</span>
|
||||||
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
|
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
|
||||||
|
|||||||
43
apps/finance/services/api/main/templates/org_create.html
Normal file
43
apps/finance/services/api/main/templates/org_create.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div style="max-width:480px; margin:0 auto;">
|
||||||
|
<h1 style="margin-bottom:24px;">New Organisation</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error" style="margin-bottom:16px; padding:12px 16px; background:var(--red-dim); border-radius:var(--radius-sm);">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="/orgs/new">
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">Organisation name</label>
|
||||||
|
<input class="form-input" type="text" name="name" value="{{.Name}}" placeholder="Scouts Group Lisboa" required autofocus
|
||||||
|
oninput="autoSlug(this)">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<label class="form-label">URL slug <span style="color:var(--text3); font-weight:400;">(used in all links)</span></label>
|
||||||
|
<div style="display:flex; align-items:center; gap:0; border:1px solid var(--border2); border-radius:var(--radius-sm); overflow:hidden; background:var(--bg3);">
|
||||||
|
<span style="padding:0 10px; color:var(--text3); font-size:13px; white-space:nowrap; border-right:1px solid var(--border2);">finance.local/orgs/</span>
|
||||||
|
<input style="flex:1; border:none; background:transparent; padding:10px 12px; color:var(--text); font-size:13px; outline:none;" type="text" name="slug" id="slug" value="{{.Slug}}" placeholder="scouts-lisboa" required pattern="[a-z0-9-]{2,40}">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:var(--text3); margin-top:5px;">Lowercase letters, digits and hyphens only.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Create organisation</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function autoSlug(nameInput) {
|
||||||
|
const slug = document.getElementById('slug');
|
||||||
|
if (slug.dataset.edited) return;
|
||||||
|
slug.value = nameInput.value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40);
|
||||||
|
}
|
||||||
|
document.getElementById('slug').addEventListener('input', function() {
|
||||||
|
this.dataset.edited = '1';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
140
apps/finance/services/api/main/templates/org_home.html
Normal file
140
apps/finance/services/api/main/templates/org_home.html
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<!-- Breadcrumb + actions -->
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;"><a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /</div>
|
||||||
|
<h1>{{$d.Org.Name}}</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/teams" class="btn btn-outline btn-sm">Teams</a>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/members" class="btn btn-outline btn-sm">Members</a>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/invite" class="btn btn-primary btn-sm">+ Invite</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<div class="grid" style="margin-bottom:24px;">
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Teams</h2>
|
||||||
|
<div class="value" style="color:var(--text);">{{len $d.Teams}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Members</h2>
|
||||||
|
<div class="value" style="color:var(--text);">{{len $d.Members}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Fiscal years</h2>
|
||||||
|
<div class="value" style="color:var(--text);">{{len $d.FiscalYears}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active fiscal year banner -->
|
||||||
|
{{if $d.ActiveYear}}
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:24px; border-color:var(--accent); background:linear-gradient(135deg, var(--bg2), var(--bg3));">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.07em; color:var(--accent); margin-bottom:4px;">ACTIVE YEAR</div>
|
||||||
|
<div style="font-size:20px; font-weight:700;">{{$d.ActiveYear.Label}}</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:4px;">
|
||||||
|
{{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Fiscal years list -->
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<h2>Fiscal years</h2>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<button onclick="document.getElementById('new-year-modal').style.display='flex'" class="btn btn-outline btn-sm">+ New year</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.FiscalYears}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Period</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.FiscalYears}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">{{.Label}}</td>
|
||||||
|
<td style="color:var(--text2); font-size:13px;">{{dateShort .StartDate}} — {{dateShort .EndDate}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq (print .Status) "active"}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px; background:rgba(74,222,128,0.12); color:var(--green);">active</span>
|
||||||
|
{{else if eq (print .Status) "closed"}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px; background:var(--bg3); color:var(--text3);">closed</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">draft</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{.ID}}/events" class="btn btn-outline btn-sm">View</a>
|
||||||
|
{{if and (eq (print .Status) "draft") (eq (print $d.MyRole) "admin")}}
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{.ID}}/activate" style="display:inline;" onsubmit="return confirm('Activate this fiscal year? All events must be approved first.')">
|
||||||
|
<button class="btn btn-primary btn-sm">Activate</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">📅</div>
|
||||||
|
<h3>No fiscal years yet</h3>
|
||||||
|
<p style="margin-bottom:16px;">Create a fiscal year to start planning events and budgets.</p>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<button onclick="document.getElementById('new-year-modal').style.display='flex'" class="btn btn-primary">Create fiscal year</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- New fiscal year modal -->
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<div id="new-year-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center;">
|
||||||
|
<div class="card" style="width:100%; max-width:420px; margin:16px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>New fiscal year</h2>
|
||||||
|
<button onclick="document.getElementById('new-year-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years">
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Label</label>
|
||||||
|
<input class="form-input" type="text" name="label" placeholder="2025" required>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Start date</label>
|
||||||
|
<input class="form-input" type="date" name="start_date" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">End date</label>
|
||||||
|
<input class="form-input" type="date" name="end_date" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
90
apps/finance/services/api/main/templates/org_invite.html
Normal file
90
apps/finance/services/api/main/templates/org_invite.html
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="max-width:520px; margin:0 auto;">
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/members" style="color:var(--text3); text-decoration:none;">Members</a> /
|
||||||
|
</div>
|
||||||
|
<h1 style="margin-bottom:24px;">Invite member</h1>
|
||||||
|
|
||||||
|
{{if $d.Link}}
|
||||||
|
<!-- Success state: show the generated link -->
|
||||||
|
<div class="card" style="border-color:var(--accent);">
|
||||||
|
<div style="font-size:13px; font-weight:600; color:var(--green); margin-bottom:12px;">✓ Invite created</div>
|
||||||
|
<p style="font-size:13px; color:var(--text2); margin-bottom:14px;">
|
||||||
|
Copy this link and send it to the invitee. It expires in 7 days.<br>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">(Email delivery coming in a future update.)</span>
|
||||||
|
</p>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<input id="invite-link" class="form-input" type="text" value="{{$d.Link}}" readonly
|
||||||
|
style="font-size:12px; font-family:monospace; flex:1;">
|
||||||
|
<button onclick="copyLink()" class="btn btn-primary btn-sm" id="copy-btn">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:20px; display:flex; gap:8px;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/invite" class="btn btn-outline btn-sm">Invite another</a>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/members" class="btn btn-outline btn-sm">Back to members</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function copyLink() {
|
||||||
|
const inp = document.getElementById('invite-link');
|
||||||
|
inp.select();
|
||||||
|
navigator.clipboard.writeText(inp.value).then(() => {
|
||||||
|
const btn = document.getElementById('copy-btn');
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = 'Copy', 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
{{if $d.Error}}
|
||||||
|
<div class="error" style="margin-bottom:16px; padding:12px 16px; background:var(--red-dim); border-radius:var(--radius-sm);">{{$d.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/invite">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Email address</label>
|
||||||
|
<input class="form-input" type="email" name="email" placeholder="colleague@example.com" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select class="form-input" name="role">
|
||||||
|
<option value="member">Member — submit requests, manage own team events</option>
|
||||||
|
<option value="viewer">Viewer — read-only access to own team</option>
|
||||||
|
<option value="finance">Finance — approve requests, manage ledger</option>
|
||||||
|
<option value="admin">Admin — full control</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px; color:var(--text3); margin-top:5px;">
|
||||||
|
All financial approvals are handled by finance/admin regardless of team.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Teams}}
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">Team assignment <span style="color:var(--text3); font-weight:400;">(optional — leave blank for org-wide access)</span></label>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px; margin-top:8px;">
|
||||||
|
{{range $d.Teams}}
|
||||||
|
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; font-size:13px;">
|
||||||
|
<input type="checkbox" name="team_ids" value="{{.ID}}"
|
||||||
|
style="width:15px; height:15px; accent-color:var(--accent);">
|
||||||
|
<span>{{.Name}}</span>
|
||||||
|
{{if eq (print .Type) "guest"}}
|
||||||
|
<span style="font-size:10px; padding:2px 7px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">guest</span>
|
||||||
|
{{end}}
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Generate invite link</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
33
apps/finance/services/api/main/templates/org_join.html
Normal file
33
apps/finance/services/api/main/templates/org_join.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="max-width:440px; margin:0 auto; padding-top:32px;">
|
||||||
|
<div class="card" style="text-align:center;">
|
||||||
|
<div style="font-size:48px; margin-bottom:16px;">🏢</div>
|
||||||
|
<h1 style="font-size:22px; margin-bottom:8px;">You've been invited</h1>
|
||||||
|
<p style="color:var(--text2); font-size:14px; margin-bottom:24px;">
|
||||||
|
Join <strong style="color:var(--text);">{{$d.Org.Name}}</strong> as
|
||||||
|
<strong style="color:var(--accent2);">{{$d.Invite.Role}}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if $d.Invite.TeamIDs}}
|
||||||
|
<div style="margin-bottom:20px; padding:12px 16px; background:var(--bg3); border-radius:var(--radius-sm); text-align:left;">
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:6px;">ASSIGNED TEAMS</div>
|
||||||
|
{{range $d.Invite.TeamIDs}}
|
||||||
|
<div style="font-size:13px; color:var(--text2); padding:2px 0;">· {{.}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-bottom:24px;">
|
||||||
|
Joining as <strong style="color:var(--text);">{{$d.Email}}</strong>.<br>
|
||||||
|
This invite expires {{dateShort $d.Invite.ExpiresAt}}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="/join/{{$d.Invite.Token}}">
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%; margin-bottom:10px;">Accept & join</button>
|
||||||
|
</form>
|
||||||
|
<a href="/" style="font-size:13px; color:var(--text3);">Decline</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
30
apps/finance/services/api/main/templates/org_list.html
Normal file
30
apps/finance/services/api/main/templates/org_list.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
|
||||||
|
<h1>Organisations</h1>
|
||||||
|
<a href="/orgs/new" class="btn btn-primary">+ New organisation</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Orgs}}
|
||||||
|
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||||
|
{{range $d.Orgs}}
|
||||||
|
<a href="/orgs/{{.Org.Slug}}" style="text-decoration:none;">
|
||||||
|
<div class="card" style="display:flex; justify-content:space-between; align-items:center; padding:18px 24px; cursor:pointer; transition:border-color 0.15s;" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor=''">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:16px; font-weight:700; color:var(--text);">{{.Org.Name}}</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:3px;">{{.Org.Slug}}</div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:11px; font-weight:600; padding:4px 10px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">{{.Role}}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state">
|
||||||
|
<div style="font-size:48px; margin-bottom:16px;">🏢</div>
|
||||||
|
<h3>No organisations yet</h3>
|
||||||
|
<p style="margin-bottom:20px;">Create one to start planning team finances.</p>
|
||||||
|
<a href="/orgs/new" class="btn btn-primary">Create organisation</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
99
apps/finance/services/api/main/templates/org_members.html
Normal file
99
apps/finance/services/api/main/templates/org_members.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
</div>
|
||||||
|
<h1>Members</h1>
|
||||||
|
</div>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/invite" class="btn btn-primary">+ Invite member</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members table -->
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden; margin-bottom:24px;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Teams</th>
|
||||||
|
{{if eq $d.MyRole "admin"}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $m := $d.Members}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">{{$m.Email}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/members/{{$m.ID}}/role" style="display:inline;">
|
||||||
|
<select name="role" onchange="this.form.submit()" style="background:var(--bg3); border:1px solid var(--border2); color:var(--text); border-radius:6px; padding:3px 6px; font-size:12px; cursor:pointer;">
|
||||||
|
<option value="admin" {{if eq $m.Role "admin"}}selected{{end}}>admin</option>
|
||||||
|
<option value="finance" {{if eq $m.Role "finance"}}selected{{end}}>finance</option>
|
||||||
|
<option value="member" {{if eq $m.Role "member"}}selected{{end}}>member</option>
|
||||||
|
<option value="viewer" {{if eq $m.Role "viewer"}}selected{{end}}>viewer</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">{{$m.Role}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px; color:var(--text2);">
|
||||||
|
{{if $m.TeamIDs}}
|
||||||
|
{{range $i, $tid := $m.TeamIDs}}
|
||||||
|
{{range $d.Teams}}{{if eq .ID $tid}}{{if $i}}, {{end}}{{.Name}}{{end}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<span style="color:var(--text3);">all teams</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/members/{{$m.ID}}/remove" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Remove {{$m.Email}} from this organisation?')">
|
||||||
|
<button class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red);">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending invites -->
|
||||||
|
{{if and (eq $d.MyRole "admin") $d.Invites}}
|
||||||
|
<h2 style="margin-bottom:12px;">Pending invites</h2>
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.Invites}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">{{.Email}}</td>
|
||||||
|
<td><span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">{{.Role}}</span></td>
|
||||||
|
<td style="font-size:12px; color:var(--text3);">{{dateShort .ExpiresAt}}</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/invites/{{.ID}}/revoke" style="display:inline;">
|
||||||
|
<button class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red);">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
90
apps/finance/services/api/main/templates/org_teams.html
Normal file
90
apps/finance/services/api/main/templates/org_teams.html
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
</div>
|
||||||
|
<h1>Teams</h1>
|
||||||
|
</div>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<button onclick="document.getElementById('new-team-modal').style.display='flex'" class="btn btn-primary">+ New team</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Teams}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Members</th>
|
||||||
|
{{if eq $d.MyRole "admin"}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $t := $d.Teams}}
|
||||||
|
{{$count := 0}}
|
||||||
|
{{range $d.Members}}{{range .TeamIDs}}{{if eq . $t.ID}}{{$count = add $count 1}}{{end}}{{end}}{{end}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">{{$t.Name}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq (print $t.Type) "guest"}}
|
||||||
|
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">guest</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">internal</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text2);">{{$count}}</td>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/teams/{{$t.ID}}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete team {{$t.Name}}? Members will lose team assignment.')">
|
||||||
|
<button class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red);">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">👥</div>
|
||||||
|
<h3>No teams yet</h3>
|
||||||
|
<p style="margin-bottom:16px;">Teams group members and own events. Guest teams have scoped visibility.</p>
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<button onclick="document.getElementById('new-team-modal').style.display='flex'" class="btn btn-primary">Create first team</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq $d.MyRole "admin"}}
|
||||||
|
<div id="new-team-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center;">
|
||||||
|
<div class="card" style="width:100%; max-width:400px; margin:16px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>New team</h2>
|
||||||
|
<button onclick="document.getElementById('new-team-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/teams">
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Team name</label>
|
||||||
|
<input class="form-input" type="text" name="name" placeholder="Marketing" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-input" name="type">
|
||||||
|
<option value="internal">Internal — full org visibility</option>
|
||||||
|
<option value="guest">Guest — sees own team data only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Create team</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Loading…
x
Reference in New Issue
Block a user