Add admin CRUD for contacts, deals, users, and organizations

- Admin section accessible to owner/admin roles only
- Full CRUD for contacts with type filtering (buyer/internal/advisor)
- Full CRUD for deals with all fields (stage, size, dates, etc.)
- Full CRUD for users/profiles with role management
- Full CRUD for organizations
- Admin dashboard with entity counts
- Consistent dark theme matching existing UI
- Breadcrumb navigation throughout admin section
- Delete confirmation dialogs
This commit is contained in:
James 2026-02-17 04:41:22 -05:00
parent 3720ed7b84
commit e4552aef5b
4 changed files with 956 additions and 0 deletions

408
internal/handler/admin.go Normal file
View File

@ -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)
}

View File

@ -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))
}

524
templates/admin.templ Normal file
View File

@ -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") {
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold">Admin</h1>
<p class="text-sm text-gray-500 mt-1">Manage your organization's data.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@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")
</div>
</div>
}
}
templ adminCard(href string, title string, count string, desc string, color string) {
<a href={ templ.SafeURL(href) } class="block bg-gray-900 rounded-lg border border-gray-800 p-5 hover:border-gray-700 transition group">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-400 group-hover:text-gray-200 transition">{ title }</h3>
<span class={ "text-2xl font-bold",
templ.KV("text-teal-400", color == "teal"),
templ.KV("text-amber-400", color == "amber"),
templ.KV("text-green-400", color == "green"),
templ.KV("text-purple-400", color == "purple") }>{ count }</span>
</div>
<p class="text-xs text-gray-600">{ desc }</p>
</a>
}
// --- Contacts List ---
templ AdminContacts(profile *model.Profile, contacts []*model.Contact, filter string) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Contacts")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Contacts</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(contacts)) }</span>
</div>
<div class="flex items-center gap-3">
<div class="flex gap-1">
@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")
</div>
<a href="/admin/contacts/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Contact
</a>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Type</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(contacts) == 0 {
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-600 text-sm">No contacts found.</td></tr>
}
for _, c := range contacts {
<tr class="hover:bg-gray-800/30 transition group">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ contactInitials(c.FullName) }
</div>
<div>
<span class="text-sm font-medium">{ c.FullName }</span>
<p class="text-xs text-gray-500">{ c.Title }</p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{ c.Company }</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ c.Email }</td>
<td class="px-4 py-3">@ContactTypeBadge(c.ContactType)</td>
<td class="px-4 py-3">
<div class="flex gap-1 flex-wrap">
for _, tag := range splitTags(c.Tags) {
if tag != "" {
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{ tag }</span>
}
}
</div>
</td>
<td class="px-4 py-3 text-right">
@editDeleteActions("/admin/contacts", c.ID)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
// --- Contact Form ---
templ AdminContactForm(profile *model.Profile, contact *model.Contact) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Contacts", "/admin/contacts", formTitle("Contact", contact.ID))
<form method="POST" action="/admin/contacts/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ 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")
</form>
</div>
}
}
// --- Deals List ---
templ AdminDeals(profile *model.Profile, deals []*model.Deal) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Deals")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Deals</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(deals)) }</span>
</div>
<a href="/admin/deals/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Deal
</a>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Target Company</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Stage</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Status</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(deals) == 0 {
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-600 text-sm">No deals found.</td></tr>
}
for _, d := range deals {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3 text-sm font-medium">{ d.Name }</td>
<td class="px-4 py-3 text-sm text-gray-400">{ d.TargetCompany }</td>
<td class="px-4 py-3">@StageBadge(d.Stage)</td>
<td class="px-4 py-3 text-sm text-gray-400">{ formatDealSizeCurrency(d.DealSize, d.Currency) }</td>
<td class="px-4 py-3">
if d.IsArchived {
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">Archived</span>
} else {
<span class="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-400">Active</span>
}
</td>
<td class="px-4 py-3 text-right">
@editDeleteActions("/admin/deals", d.ID)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
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") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Deals", "/admin/deals", formTitle("Deal", deal.ID))
<form method="POST" action="/admin/deals/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ 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"},
})
<div class="grid grid-cols-2 gap-4">
@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"},
})
</div>
<div class="grid grid-cols-2 gap-4">
@formField("ioi_date", "IOI Date", "date", deal.IOIDate, false)
@formField("loi_date", "LOI Date", "date", deal.LOIDate, false)
</div>
<div class="grid grid-cols-2 gap-4">
@formField("exclusivity_end", "Exclusivity End", "date", deal.ExclusivityEnd, false)
@formField("expected_close_date", "Expected Close", "date", deal.ExpectedCloseDate, false)
</div>
@formField("close_probability", "Close Probability (%)", "number", fmt.Sprintf("%d", deal.CloseProbability), false)
@formCheckbox("is_archived", "Archived", deal.IsArchived)
@formActions("/admin/deals")
</form>
</div>
}
}
// --- Users List ---
templ AdminUsers(profile *model.Profile, users []*model.Profile) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Users")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Users</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(users)) }</span>
</div>
<a href="/admin/users/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New User
</a>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(users) == 0 {
<tr><td colspan="4" class="px-4 py-8 text-center text-gray-600 text-sm">No users found.</td></tr>
}
for _, u := range users {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ initials(u.FullName) }
</div>
<span class="text-sm font-medium">{ u.FullName }</span>
</div>
</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ u.Email }</td>
<td class="px-4 py-3">@roleBadge(u.Role)</td>
<td class="px-4 py-3 text-right">
if u.ID != profile.ID {
@editDeleteActions("/admin/users", u.ID)
} else {
<a href={ templ.SafeURL("/admin/users/edit?id=" + u.ID) } class="text-xs text-gray-500 hover:text-teal-400 transition">Edit</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
templ roleBadge(role string) {
<span class={ "text-xs px-1.5 py-0.5 rounded font-medium",
templ.KV("bg-purple-500/10 text-purple-400", role == "owner"),
templ.KV("bg-teal-500/10 text-teal-400", role == "admin"),
templ.KV("bg-green-500/10 text-green-400", role == "member"),
templ.KV("bg-gray-700 text-gray-400", role == "viewer") }>
{ capitalizeFirst(role) }
</span>
}
// --- User Form ---
templ AdminUserForm(profile *model.Profile, user *model.Profile) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Users", "/admin/users", formTitle("User", user.ID))
<form method="POST" action="/admin/users/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ 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")
</form>
</div>
}
}
// --- Organizations List ---
templ AdminOrgs(profile *model.Profile, orgs []*model.Organization) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Organizations")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Organizations</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(orgs)) }</span>
</div>
<a href="/admin/organizations/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Organization
</a>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(orgs) == 0 {
<tr><td colspan="3" class="px-4 py-8 text-center text-gray-600 text-sm">No organizations found.</td></tr>
}
for _, o := range orgs {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3 text-sm font-medium">{ o.Name }</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ o.Slug }</td>
<td class="px-4 py-3 text-right">
@editDeleteActions("/admin/organizations", o.ID)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
// --- Organization Form ---
templ AdminOrgForm(profile *model.Profile, org *model.Organization) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Organizations", "/admin/organizations", formTitle("Organization", org.ID))
<form method="POST" action="/admin/organizations/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ org.ID }/>
@formField("name", "Name", "text", org.Name, true)
@formField("slug", "Slug", "text", org.Slug, true)
@formActions("/admin/organizations")
</form>
</div>
}
}
// --- 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) {
<nav class="flex items-center gap-2 text-sm">
<a href="/admin" class="text-gray-500 hover:text-gray-300 transition">Admin</a>
<span class="text-gray-700"></span>
<span class="text-gray-300">{ section }</span>
</nav>
}
templ adminBreadcrumbSub(section string, sectionHref string, sub string) {
<nav class="flex items-center gap-2 text-sm">
<a href="/admin" class="text-gray-500 hover:text-gray-300 transition">Admin</a>
<span class="text-gray-700"></span>
<a href={ templ.SafeURL(sectionHref) } class="text-gray-500 hover:text-gray-300 transition">{ section }</a>
<span class="text-gray-700"></span>
<span class="text-gray-300">{ sub }</span>
</nav>
}
templ filterPill(href string, label string, active bool) {
<a href={ templ.SafeURL(href) } class={ "text-xs px-2.5 py-1 rounded-full transition",
templ.KV("bg-teal-500/10 text-teal-400 font-medium", active),
templ.KV("text-gray-500 hover:text-gray-300 hover:bg-gray-800", !active) }>
{ label }
</a>
}
templ editDeleteActions(basePath string, id string) {
<div class="flex items-center justify-end gap-2">
<a href={ templ.SafeURL(basePath + "/edit?id=" + id) } class="text-xs text-gray-500 hover:text-teal-400 transition">Edit</a>
<a href={ templ.SafeURL(basePath + "/delete?id=" + id) } class="text-xs text-gray-500 hover:text-red-400 transition" onclick="return confirm('Are you sure you want to delete this?')">Delete</a>
</div>
}
templ formField(name string, label string, fieldType string, value string, required bool) {
<div>
<label for={ name } class="block text-sm font-medium text-gray-300 mb-1">{ label }</label>
if required {
<input type={ fieldType } id={ name } name={ name } value={ value } required
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition"/>
} else {
<input type={ fieldType } id={ name } name={ name } value={ value }
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition"/>
}
</div>
}
templ formTextarea(name string, label string, value string) {
<div>
<label for={ name } class="block text-sm font-medium text-gray-300 mb-1">{ label }</label>
<textarea id={ name } name={ name } rows="3"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition">{ value }</textarea>
</div>
}
templ formSelect(name string, label string, current string, options []SelectOption) {
<div>
<label for={ name } class="block text-sm font-medium text-gray-300 mb-1">{ label }</label>
<select id={ name } name={ name }
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition">
for _, opt := range options {
if opt.Value == current {
<option value={ opt.Value } selected>{ opt.Label }</option>
} else {
<option value={ opt.Value }>{ opt.Label }</option>
}
}
</select>
</div>
}
templ formCheckbox(name string, label string, checked bool) {
<div class="flex items-center gap-2">
if checked {
<input type="checkbox" id={ name } name={ name } checked class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
} else {
<input type="checkbox" id={ name } name={ name } class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
}
<label for={ name } class="text-sm text-gray-300">{ label }</label>
</div>
}
templ formActions(cancelHref string) {
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="h-9 px-5 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Save</button>
<a href={ templ.SafeURL(cancelHref) } class="h-9 px-5 rounded-lg border border-gray-700 text-gray-400 text-sm font-medium flex items-center hover:bg-gray-800 transition">Cancel</a>
</div>
}

View File

@ -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() {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
}
templ svgCog() {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
}
templ svgShield() {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
}