diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..93aec71 --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,408 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + + "dealroom/internal/model" + "dealroom/templates" +) + +// requireAdmin wraps requireAuth and checks for owner/admin role +func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + if profile.Role != "owner" && profile.Role != "admin" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func generateID(prefix string) string { + b := make([]byte, 8) + rand.Read(b) + return prefix + "-" + hex.EncodeToString(b) +} + +// --- Admin Dashboard --- + +func (h *Handler) handleAdmin(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + + var stats templates.AdminStats + h.db.QueryRow("SELECT COUNT(*) FROM contacts WHERE organization_id = ?", profile.OrganizationID).Scan(&stats.ContactCount) + h.db.QueryRow("SELECT COUNT(*) FROM deals WHERE organization_id = ?", profile.OrganizationID).Scan(&stats.DealCount) + h.db.QueryRow("SELECT COUNT(*) FROM profiles WHERE organization_id = ?", profile.OrganizationID).Scan(&stats.UserCount) + h.db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&stats.OrgCount) + + templates.AdminDashboard(profile, stats).Render(r.Context(), w) +} + +// --- Contacts CRUD --- + +func (h *Handler) handleAdminContacts(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + + filter := r.URL.Query().Get("type") + query := "SELECT id, full_name, email, phone, company, title, contact_type, tags, notes FROM contacts WHERE organization_id = ?" + args := []interface{}{profile.OrganizationID} + if filter != "" { + query += " AND contact_type = ?" + args = append(args, filter) + } + query += " ORDER BY full_name" + + rows, err := h.db.Query(query, args...) + if err != nil { + http.Error(w, "Error loading contacts", 500) + return + } + defer rows.Close() + + var contacts []*model.Contact + for rows.Next() { + c := &model.Contact{} + rows.Scan(&c.ID, &c.FullName, &c.Email, &c.Phone, &c.Company, &c.Title, &c.ContactType, &c.Tags, &c.Notes) + contacts = append(contacts, c) + } + + templates.AdminContacts(profile, contacts, filter).Render(r.Context(), w) +} + +func (h *Handler) handleAdminContactForm(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + + var contact model.Contact + if id != "" { + h.db.QueryRow("SELECT id, full_name, email, phone, company, title, contact_type, tags, notes FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan( + &contact.ID, &contact.FullName, &contact.Email, &contact.Phone, &contact.Company, &contact.Title, &contact.ContactType, &contact.Tags, &contact.Notes) + } + + templates.AdminContactForm(profile, &contact).Render(r.Context(), w) +} + +func (h *Handler) handleAdminContactSave(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + r.ParseForm() + + id := r.FormValue("id") + fullName := strings.TrimSpace(r.FormValue("full_name")) + email := strings.TrimSpace(r.FormValue("email")) + phone := strings.TrimSpace(r.FormValue("phone")) + company := strings.TrimSpace(r.FormValue("company")) + title := strings.TrimSpace(r.FormValue("title")) + contactType := r.FormValue("contact_type") + tags := strings.TrimSpace(r.FormValue("tags")) + notes := strings.TrimSpace(r.FormValue("notes")) + + if fullName == "" { + http.Error(w, "Name is required", 400) + return + } + + if id == "" { + id = generateID("contact") + _, err := h.db.Exec("INSERT INTO contacts (id, organization_id, full_name, email, phone, company, title, contact_type, tags, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, profile.OrganizationID, fullName, email, phone, company, title, contactType, tags, notes) + if err != nil { + http.Error(w, "Error creating contact", 500) + return + } + } else { + _, err := h.db.Exec("UPDATE contacts SET full_name=?, email=?, phone=?, company=?, title=?, contact_type=?, tags=?, notes=? WHERE id=? AND organization_id=?", + fullName, email, phone, company, title, contactType, tags, notes, id, profile.OrganizationID) + if err != nil { + http.Error(w, "Error updating contact", 500) + return + } + } + + http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther) +} + +func (h *Handler) handleAdminContactDelete(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "Missing id", 400) + return + } + + h.db.Exec("DELETE FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID) + http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther) +} + +// --- Deals CRUD --- + +func (h *Handler) handleAdminDeals(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + + rows, err := h.db.Query("SELECT id, name, description, target_company, stage, deal_size, currency, is_archived, created_at FROM deals WHERE organization_id = ? ORDER BY created_at DESC", profile.OrganizationID) + if err != nil { + http.Error(w, "Error loading deals", 500) + return + } + defer rows.Close() + + var deals []*model.Deal + for rows.Next() { + d := &model.Deal{} + rows.Scan(&d.ID, &d.Name, &d.Description, &d.TargetCompany, &d.Stage, &d.DealSize, &d.Currency, &d.IsArchived, &d.CreatedAt) + deals = append(deals, d) + } + + templates.AdminDeals(profile, deals).Render(r.Context(), w) +} + +func (h *Handler) handleAdminDealForm(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + + var deal model.Deal + deal.Currency = "USD" + if id != "" { + h.db.QueryRow("SELECT id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, is_archived FROM deals WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan( + &deal.ID, &deal.Name, &deal.Description, &deal.TargetCompany, &deal.Stage, &deal.DealSize, &deal.Currency, &deal.IOIDate, &deal.LOIDate, &deal.ExclusivityEnd, &deal.ExpectedCloseDate, &deal.CloseProbability, &deal.IsArchived) + } + + templates.AdminDealForm(profile, &deal).Render(r.Context(), w) +} + +func (h *Handler) handleAdminDealSave(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + r.ParseForm() + + id := r.FormValue("id") + name := strings.TrimSpace(r.FormValue("name")) + description := strings.TrimSpace(r.FormValue("description")) + targetCompany := strings.TrimSpace(r.FormValue("target_company")) + stage := r.FormValue("stage") + dealSize, _ := strconv.ParseFloat(r.FormValue("deal_size"), 64) + currency := r.FormValue("currency") + ioiDate := r.FormValue("ioi_date") + loiDate := r.FormValue("loi_date") + exclusivityEnd := r.FormValue("exclusivity_end") + expectedClose := r.FormValue("expected_close_date") + closeProbability, _ := strconv.Atoi(r.FormValue("close_probability")) + isArchived := r.FormValue("is_archived") == "on" + + if name == "" { + http.Error(w, "Name is required", 400) + return + } + if currency == "" { + currency = "USD" + } + if stage == "" { + stage = "pipeline" + } + + if id == "" { + id = generateID("deal") + _, err := h.db.Exec(`INSERT INTO deals (id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, is_archived, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, profile.OrganizationID, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, isArchived, profile.ID) + if err != nil { + http.Error(w, fmt.Sprintf("Error creating deal: %v", err), 500) + return + } + } else { + _, err := h.db.Exec(`UPDATE deals SET name=?, description=?, target_company=?, stage=?, deal_size=?, currency=?, ioi_date=?, loi_date=?, exclusivity_end_date=?, expected_close_date=?, close_probability=?, is_archived=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?`, + name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, isArchived, id, profile.OrganizationID) + if err != nil { + http.Error(w, fmt.Sprintf("Error updating deal: %v", err), 500) + return + } + } + + http.Redirect(w, r, "/admin/deals", http.StatusSeeOther) +} + +func (h *Handler) handleAdminDealDelete(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "Missing id", 400) + return + } + + // Delete related data first + h.db.Exec("DELETE FROM deal_activity WHERE deal_id = ?", id) + h.db.Exec("DELETE FROM diligence_requests WHERE deal_id = ?", id) + h.db.Exec("DELETE FROM files WHERE deal_id = ?", id) + h.db.Exec("DELETE FROM folders WHERE deal_id = ?", id) + h.db.Exec("DELETE FROM deals WHERE id = ? AND organization_id = ?", id, profile.OrganizationID) + + http.Redirect(w, r, "/admin/deals", http.StatusSeeOther) +} + +// --- Users/Profiles CRUD --- + +func (h *Handler) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + + rows, err := h.db.Query("SELECT id, email, full_name, role, created_at FROM profiles WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID) + if err != nil { + http.Error(w, "Error loading users", 500) + return + } + defer rows.Close() + + var users []*model.Profile + for rows.Next() { + u := &model.Profile{} + rows.Scan(&u.ID, &u.Email, &u.FullName, &u.Role, &u.CreatedAt) + users = append(users, u) + } + + templates.AdminUsers(profile, users).Render(r.Context(), w) +} + +func (h *Handler) handleAdminUserForm(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + + var user model.Profile + if id != "" { + h.db.QueryRow("SELECT id, email, full_name, role FROM profiles WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan( + &user.ID, &user.Email, &user.FullName, &user.Role) + } + + templates.AdminUserForm(profile, &user).Render(r.Context(), w) +} + +func (h *Handler) handleAdminUserSave(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + r.ParseForm() + + id := r.FormValue("id") + email := strings.TrimSpace(r.FormValue("email")) + fullName := strings.TrimSpace(r.FormValue("full_name")) + role := r.FormValue("role") + + if email == "" || fullName == "" { + http.Error(w, "Email and name are required", 400) + return + } + + if id == "" { + id = generateID("user") + _, err := h.db.Exec("INSERT INTO profiles (id, email, full_name, organization_id, role, password_hash) VALUES (?, ?, ?, ?, ?, 'demo')", + id, email, fullName, profile.OrganizationID, role) + if err != nil { + http.Error(w, "Error creating user", 500) + return + } + } else { + _, err := h.db.Exec("UPDATE profiles SET email=?, full_name=?, role=? WHERE id=? AND organization_id=?", + email, fullName, role, id, profile.OrganizationID) + if err != nil { + http.Error(w, "Error updating user", 500) + return + } + } + + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) +} + +func (h *Handler) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + if id == "" || id == profile.ID { + http.Error(w, "Cannot delete yourself", 400) + return + } + + h.db.Exec("DELETE FROM sessions WHERE user_id = ?", id) + h.db.Exec("DELETE FROM profiles WHERE id = ? AND organization_id = ?", id, profile.OrganizationID) + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) +} + +// --- Organizations CRUD --- + +func (h *Handler) handleAdminOrgs(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + + rows, err := h.db.Query("SELECT id, name, slug, created_at FROM organizations ORDER BY name") + if err != nil { + http.Error(w, "Error loading organizations", 500) + return + } + defer rows.Close() + + var orgs []*model.Organization + for rows.Next() { + o := &model.Organization{} + rows.Scan(&o.ID, &o.Name, &o.Slug, &o.CreatedAt) + orgs = append(orgs, o) + } + + templates.AdminOrgs(profile, orgs).Render(r.Context(), w) +} + +func (h *Handler) handleAdminOrgForm(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + id := r.URL.Query().Get("id") + + var org model.Organization + if id != "" { + h.db.QueryRow("SELECT id, name, slug FROM organizations WHERE id = ?", id).Scan(&org.ID, &org.Name, &org.Slug) + } + + templates.AdminOrgForm(profile, &org).Render(r.Context(), w) +} + +func (h *Handler) handleAdminOrgSave(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + id := r.FormValue("id") + name := strings.TrimSpace(r.FormValue("name")) + slug := strings.TrimSpace(r.FormValue("slug")) + + if name == "" || slug == "" { + http.Error(w, "Name and slug are required", 400) + return + } + + if id == "" { + id = generateID("org") + _, err := h.db.Exec("INSERT INTO organizations (id, name, slug) VALUES (?, ?, ?)", id, name, slug) + if err != nil { + http.Error(w, "Error creating organization", 500) + return + } + } else { + _, err := h.db.Exec("UPDATE organizations SET name=?, slug=? WHERE id=?", name, slug, id) + if err != nil { + http.Error(w, "Error updating organization", 500) + return + } + } + + http.Redirect(w, r, "/admin/organizations", http.StatusSeeOther) +} + +func (h *Handler) handleAdminOrgDelete(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "Missing id", 400) + return + } + + // Check if org has users + var count int + h.db.QueryRow("SELECT COUNT(*) FROM profiles WHERE organization_id = ?", id).Scan(&count) + if count > 0 { + http.Error(w, "Cannot delete organization with users", 400) + return + } + + h.db.Exec("DELETE FROM organizations WHERE id = ?", id) + http.Redirect(w, r, "/admin/organizations", http.StatusSeeOther) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index ba52950..aa0ea05 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -48,6 +48,25 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/audit", h.requireAuth(h.handleAuditLog)) mux.HandleFunc("/analytics", h.requireAuth(h.handleAnalytics)) + // Admin CRUD + mux.HandleFunc("/admin", h.requireAdmin(h.handleAdmin)) + mux.HandleFunc("/admin/contacts", h.requireAdmin(h.handleAdminContacts)) + mux.HandleFunc("/admin/contacts/edit", h.requireAdmin(h.handleAdminContactForm)) + mux.HandleFunc("/admin/contacts/save", h.requireAdmin(h.handleAdminContactSave)) + mux.HandleFunc("/admin/contacts/delete", h.requireAdmin(h.handleAdminContactDelete)) + mux.HandleFunc("/admin/deals", h.requireAdmin(h.handleAdminDeals)) + mux.HandleFunc("/admin/deals/edit", h.requireAdmin(h.handleAdminDealForm)) + mux.HandleFunc("/admin/deals/save", h.requireAdmin(h.handleAdminDealSave)) + mux.HandleFunc("/admin/deals/delete", h.requireAdmin(h.handleAdminDealDelete)) + mux.HandleFunc("/admin/users", h.requireAdmin(h.handleAdminUsers)) + mux.HandleFunc("/admin/users/edit", h.requireAdmin(h.handleAdminUserForm)) + mux.HandleFunc("/admin/users/save", h.requireAdmin(h.handleAdminUserSave)) + mux.HandleFunc("/admin/users/delete", h.requireAdmin(h.handleAdminUserDelete)) + mux.HandleFunc("/admin/organizations", h.requireAdmin(h.handleAdminOrgs)) + mux.HandleFunc("/admin/organizations/edit", h.requireAdmin(h.handleAdminOrgForm)) + mux.HandleFunc("/admin/organizations/save", h.requireAdmin(h.handleAdminOrgSave)) + mux.HandleFunc("/admin/organizations/delete", h.requireAdmin(h.handleAdminOrgDelete)) + // HTMX partials mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment)) } diff --git a/templates/admin.templ b/templates/admin.templ new file mode 100644 index 0000000..b5c345b --- /dev/null +++ b/templates/admin.templ @@ -0,0 +1,524 @@ +package templates + +import "dealroom/internal/model" +import "fmt" +import "strings" + +func capitalizeFirst(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +type AdminStats struct { + ContactCount int + DealCount int + UserCount int + OrgCount int +} + +// --- Admin Dashboard --- + +templ AdminDashboard(profile *model.Profile, stats AdminStats) { + @Layout(profile, "admin") { +
+
+

Admin

+

Manage your organization's data.

+
+ +
+ @adminCard("/admin/contacts", "Contacts", fmt.Sprintf("%d", stats.ContactCount), "Buyers, sellers, and advisors", "teal") + @adminCard("/admin/deals", "Deals", fmt.Sprintf("%d", stats.DealCount), "Active and archived deals", "amber") + @adminCard("/admin/users", "Users", fmt.Sprintf("%d", stats.UserCount), "Team members and access", "green") + @adminCard("/admin/organizations", "Organizations", fmt.Sprintf("%d", stats.OrgCount), "Organization management", "purple") +
+
+ } +} + +templ adminCard(href string, title string, count string, desc string, color string) { + +
+

{ title }

+ { count } +
+

{ desc }

+
+} + +// --- Contacts List --- + +templ AdminContacts(profile *model.Profile, contacts []*model.Contact, filter string) { + @Layout(profile, "admin") { +
+ @adminBreadcrumb("Contacts") +
+
+

Contacts

+ { fmt.Sprintf("%d total", len(contacts)) } +
+
+
+ @filterPill("/admin/contacts", "All", filter == "") + @filterPill("/admin/contacts?type=buyer", "Buyers", filter == "buyer") + @filterPill("/admin/contacts?type=internal", "Internal", filter == "internal") + @filterPill("/admin/contacts?type=advisor", "Advisors", filter == "advisor") +
+ + + New Contact + +
+
+ +
+ + + + + + + + + + + + + if len(contacts) == 0 { + + } + for _, c := range contacts { + + + + + + + + + } + +
NameCompanyEmailTypeTagsActions
No contacts found.
+
+
+ { contactInitials(c.FullName) } +
+
+ { c.FullName } +

{ c.Title }

+
+
+
{ c.Company }{ c.Email }@ContactTypeBadge(c.ContactType) +
+ for _, tag := range splitTags(c.Tags) { + if tag != "" { + { tag } + } + } +
+
+ @editDeleteActions("/admin/contacts", c.ID) +
+
+
+ } +} + +// --- Contact Form --- + +templ AdminContactForm(profile *model.Profile, contact *model.Contact) { + @Layout(profile, "admin") { +
+ @adminBreadcrumbSub("Contacts", "/admin/contacts", formTitle("Contact", contact.ID)) +
+ + @formField("full_name", "Full Name", "text", contact.FullName, true) + @formField("email", "Email", "email", contact.Email, false) + @formField("phone", "Phone", "tel", contact.Phone, false) + @formField("company", "Company", "text", contact.Company, false) + @formField("title", "Title", "text", contact.Title, false) + @formSelect("contact_type", "Contact Type", contact.ContactType, []SelectOption{ + {Value: "buyer", Label: "Buyer"}, + {Value: "internal", Label: "Internal"}, + {Value: "advisor", Label: "Advisor"}, + }) + @formField("tags", "Tags", "text", contact.Tags, false) + @formTextarea("notes", "Notes", contact.Notes) + @formActions("/admin/contacts") +
+
+ } +} + +// --- Deals List --- + +templ AdminDeals(profile *model.Profile, deals []*model.Deal) { + @Layout(profile, "admin") { +
+ @adminBreadcrumb("Deals") +
+
+

Deals

+ { fmt.Sprintf("%d total", len(deals)) } +
+ + + New Deal + +
+ +
+ + + + + + + + + + + + + if len(deals) == 0 { + + } + for _, d := range deals { + + + + + + + + + } + +
NameTarget CompanyStageSizeStatusActions
No deals found.
{ d.Name }{ d.TargetCompany }@StageBadge(d.Stage){ formatDealSizeCurrency(d.DealSize, d.Currency) } + if d.IsArchived { + Archived + } else { + Active + } + + @editDeleteActions("/admin/deals", d.ID) +
+
+
+ } +} + +func formatDealSizeCurrency(size float64, currency string) string { + if size >= 1000000 { + return fmt.Sprintf("%s%.1fM", currency+" ", size/1000000) + } + if size > 0 { + return fmt.Sprintf("%s%.0f", currency+" ", size) + } + return "—" +} + +// --- Deal Form --- + +templ AdminDealForm(profile *model.Profile, deal *model.Deal) { + @Layout(profile, "admin") { +
+ @adminBreadcrumbSub("Deals", "/admin/deals", formTitle("Deal", deal.ID)) +
+ + @formField("name", "Deal Name", "text", deal.Name, true) + @formTextarea("description", "Description", deal.Description) + @formField("target_company", "Target Company", "text", deal.TargetCompany, false) + @formSelect("stage", "Stage", deal.Stage, []SelectOption{ + {Value: "pipeline", Label: "Pipeline"}, + {Value: "loi", Label: "LOI Stage"}, + {Value: "initial_review", Label: "Initial Review"}, + {Value: "due_diligence", Label: "Due Diligence"}, + {Value: "final_negotiation", Label: "Final Negotiation"}, + {Value: "closed", Label: "Closed"}, + {Value: "dead", Label: "Dead"}, + }) +
+ @formField("deal_size", "Deal Size", "number", fmt.Sprintf("%.0f", deal.DealSize), false) + @formSelect("currency", "Currency", deal.Currency, []SelectOption{ + {Value: "USD", Label: "USD"}, + {Value: "EUR", Label: "EUR"}, + {Value: "GBP", Label: "GBP"}, + }) +
+
+ @formField("ioi_date", "IOI Date", "date", deal.IOIDate, false) + @formField("loi_date", "LOI Date", "date", deal.LOIDate, false) +
+
+ @formField("exclusivity_end", "Exclusivity End", "date", deal.ExclusivityEnd, false) + @formField("expected_close_date", "Expected Close", "date", deal.ExpectedCloseDate, false) +
+ @formField("close_probability", "Close Probability (%)", "number", fmt.Sprintf("%d", deal.CloseProbability), false) + @formCheckbox("is_archived", "Archived", deal.IsArchived) + @formActions("/admin/deals") +
+
+ } +} + +// --- Users List --- + +templ AdminUsers(profile *model.Profile, users []*model.Profile) { + @Layout(profile, "admin") { +
+ @adminBreadcrumb("Users") +
+
+

Users

+ { fmt.Sprintf("%d total", len(users)) } +
+ + + New User + +
+ +
+ + + + + + + + + + + if len(users) == 0 { + + } + for _, u := range users { + + + + + + + } + +
NameEmailRoleActions
No users found.
+
+
+ { initials(u.FullName) } +
+ { u.FullName } +
+
{ u.Email }@roleBadge(u.Role) + if u.ID != profile.ID { + @editDeleteActions("/admin/users", u.ID) + } else { + Edit + } +
+
+
+ } +} + +templ roleBadge(role string) { + + { capitalizeFirst(role) } + +} + +// --- User Form --- + +templ AdminUserForm(profile *model.Profile, user *model.Profile) { + @Layout(profile, "admin") { +
+ @adminBreadcrumbSub("Users", "/admin/users", formTitle("User", user.ID)) +
+ + @formField("full_name", "Full Name", "text", user.FullName, true) + @formField("email", "Email", "email", user.Email, true) + @formSelect("role", "Role", user.Role, []SelectOption{ + {Value: "owner", Label: "Owner"}, + {Value: "admin", Label: "Admin"}, + {Value: "member", Label: "Member"}, + {Value: "viewer", Label: "Viewer"}, + }) + @formActions("/admin/users") +
+
+ } +} + +// --- Organizations List --- + +templ AdminOrgs(profile *model.Profile, orgs []*model.Organization) { + @Layout(profile, "admin") { +
+ @adminBreadcrumb("Organizations") +
+
+

Organizations

+ { fmt.Sprintf("%d total", len(orgs)) } +
+ + + New Organization + +
+ +
+ + + + + + + + + + if len(orgs) == 0 { + + } + for _, o := range orgs { + + + + + + } + +
NameSlugActions
No organizations found.
{ o.Name }{ o.Slug } + @editDeleteActions("/admin/organizations", o.ID) +
+
+
+ } +} + +// --- Organization Form --- + +templ AdminOrgForm(profile *model.Profile, org *model.Organization) { + @Layout(profile, "admin") { +
+ @adminBreadcrumbSub("Organizations", "/admin/organizations", formTitle("Organization", org.ID)) +
+ + @formField("name", "Name", "text", org.Name, true) + @formField("slug", "Slug", "text", org.Slug, true) + @formActions("/admin/organizations") +
+
+ } +} + +// --- Shared Components --- + +type SelectOption struct { + Value string + Label string +} + +func formTitle(entity string, id string) string { + if id == "" { + return "New " + entity + } + return "Edit " + entity +} + +templ adminBreadcrumb(section string) { + +} + +templ adminBreadcrumbSub(section string, sectionHref string, sub string) { + +} + +templ filterPill(href string, label string, active bool) { + + { label } + +} + +templ editDeleteActions(basePath string, id string) { +
+ Edit + Delete +
+} + +templ formField(name string, label string, fieldType string, value string, required bool) { +
+ + if required { + + } else { + + } +
+} + +templ formTextarea(name string, label string, value string) { +
+ + +
+} + +templ formSelect(name string, label string, current string, options []SelectOption) { +
+ + +
+} + +templ formCheckbox(name string, label string, checked bool) { +
+ if checked { + + } else { + + } + +
+} + +templ formActions(cancelHref string) { +
+ + Cancel +
+} diff --git a/templates/layout.templ b/templates/layout.templ index 3fb5fb2..814ef5f 100644 --- a/templates/layout.templ +++ b/templates/layout.templ @@ -53,6 +53,7 @@ templ Layout(profile *model.Profile, activePage string) { @sidebarLink("/analytics", "Analytics", activePage == "analytics", svgChart()) @sidebarLink("/contacts", "Contacts", activePage == "contacts", svgUsers()) @sidebarLink("/audit", "Audit Log", activePage == "audit", svgShield()) + @sidebarLink("/admin", "Admin", activePage == "admin", svgCog()) } if rbac.IsBuyer(profile.Role) { @sidebarLink("/deals", "Deal Rooms", activePage == "deals", svgFolder()) @@ -154,6 +155,10 @@ templ svgUsers() { } +templ svgCog() { + +} + templ svgShield() { }