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:
parent
3720ed7b84
commit
e4552aef5b
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue