feat(go-crm): industrial terminal-core UI redesign

Complete visual overhaul of the go-crm web interface:

- New shared layout system (internal/templates/ui.go) with dark zinc
  industrial theme, JetBrains Mono typography, grid/noise textures
- Redesigned all pages: Dashboard, Login/Signup, Clients, Customers,
  Services, Scheduling, Payments, Questions, Answers, Leads,
  Review Queue, Report, Keyword Mapping
- Tailwind CSS via CDN with custom color palette (amber/emerald/rose/sky)
- HTMX-powered interactions with CSS swap animations
- Status pills, KPI cards, data tables, empty states, inline forms
- Mobile-responsive sidebar with collapsible navigation
- All existing tests updated and passing
- Zero new build dependencies — works with existing go run/air workflow
This commit is contained in:
2026-05-23 18:00:32 -03:00
parent 744868caa1
commit 9c3bfd131d
16 changed files with 1901 additions and 791 deletions

View File

@@ -8,12 +8,11 @@ import (
"time"
"go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5"
)
// LeadReviewQueue renders the review queue: leads where needs_review = 1.
// GET /leads/review
func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -40,72 +39,39 @@ func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
services := a.serviceNames(clientID)
pendingCount := len(leads)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<title>Review Queue (%d pending)</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
h1 { display: flex; align-items: center; gap: 0.5rem; }
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.85rem; }
table { border-collapse: collapse; width: 100%%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
form { display: inline; }
select, input[type=text] { width: 100%%; box-sizing: border-box; }
.actions button { margin-right: 4px; }
.empty { padding: 2rem; color: #888; text-align: center; }
</style>
</head>
<body>
<h1>Review Queue <span class="badge">%d</span></h1>
<p>Leads with service interest not yet identified. Confirm or correct each entry.</p>
<nav><a href="/">Home</a> | <a href="/leads/all">All Leads</a> | <a href="/leads/keywords">Keyword Mapping</a> | <a href="/report">Monthly Report</a></nav>
<br>
`, pendingCount, pendingCount)
buf := templates.BufRender()
badge := ""
if pendingCount > 0 {
badge = fmt.Sprintf(`<span class="ml-2 pill pill-rose">%d</span>`, pendingCount)
}
fmt.Fprint(buf, templates.PageHeader("Review Queue", "Confirm or correct leads with unidentified service interest"))
fmt.Fprintf(buf, `<div class="mb-4">%s</div>`, badge)
if len(leads) == 0 {
fmt.Fprintf(w, `<div class="empty">No leads pending review.</div>`)
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>`, "All caught up! No leads pending review."))
} else {
fmt.Fprintf(w, `<table>
<thead>
<tr>
<th>Phone</th>
<th>Name</th>
<th>Service Interest</th>
<th>Status</th>
<th>Arrived</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reviewList">`)
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service Interest", "Status", "Arrived", "Actions"}))
for _, l := range leads {
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
fmt.Fprintf(w, `
<tr id="row-%d">
<td>%s</td>
<td>%s</td>
fmt.Fprintf(buf, `<tr id="row-%d">
<td class="font-mono text-xs text-zinc-300">%s</td>
<td class="font-medium text-zinc-200">%s</td>
<td>
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML">
<select name="service_interest">%s</select>
<select name="status">%s</select>
<input type="text" name="name" value="%s" placeholder="Name">
<div class="actions">
<button type="submit">Confirm</button>
</div>
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML" class="flex flex-col gap-2">
<select name="service_interest" class="input-industrial text-xs">%s</select>
<select name="status" class="input-industrial text-xs">%s</select>
<input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
<button type="submit" class="btn-primary btn-sm self-start">Confirm</button>
</form>
</td>
<td>%s</td>
<td>%s</td>
<td><span class="pill pill-amber">%s</span></td>
<td class="text-xs text-zinc-500">%s</td>
<td>
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML">
<button type="submit" onclick="return confirm('Delete this lead?')">Delete</button>
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML" style="display:inline">
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this lead?')">Delete</button>
</form>
</td>
</tr>`,
@@ -121,14 +87,14 @@ func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
l.LeadID, l.LeadID,
)
}
fmt.Fprintf(w, `</tbody></table>`)
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
}
fmt.Fprintf(w, `</body></html>`)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Review Queue", "review")
}
// ConfirmLeadReview handles the form submission from the review queue.
// PUT /leads/{id}/review
func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -161,11 +127,9 @@ func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<tr id="row-%d" style="display:none"></tr>`, leadID)
fmt.Fprintf(w, `<tr id="row-%d" class="htmx-swapping" style="display:none"></tr>`, leadID)
}
// LeadAllList renders all leads (not just review queue).
// GET /leads/all
func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -192,7 +156,6 @@ func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
http.Error(w, svcErr.Error(), http.StatusInternalServerError)
return
}
// convert domain.Leaf to db.Lead
leads = make([]db.Lead, len(domainLeads))
for i, dl := range domainLeads {
leads[i] = db.Lead{
@@ -225,98 +188,83 @@ func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
services := a.serviceNames(clientID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<title>All Leads</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
table { border-collapse: collapse; width: 100%%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; }
.needs-review { background: #fff3cd; }
select, input[type=text] { width: 100%%; box-sizing: border-box; }
</style>
</head>
<body>
<h1>All Leads</h1>
<nav>
<a href="/">Home</a> |
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
<a href="/leads/keywords">Keyword Mapping</a> |
<a href="/report">Monthly Report</a>
</nav>
<br>
<table>
<thead>
<tr>
<th>Phone</th><th>Name</th><th>Service</th><th>Status</th>
<th>Payment</th><th>Arrived</th><th>Last Contact</th><th>Actions</th>
</tr>
</thead>
<tbody id="leadsList">
`, pendingCount)
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("All Leads", "Complete lead database with filters and inline editing"))
for _, l := range leads {
arrived := time.Unix(l.CreatedAt, 0).Format("02/01/2006 15:04")
lastContact := time.Unix(l.LastContactAt, 0).Format("02/01/2006 15:04")
rowClass := ""
if l.NeedsReview {
rowClass = `class="needs-review"`
}
fmt.Fprintf(w, `
<tr %s id="lead-%d">
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>
<button onclick="document.getElementById('edit-%d').style.display='table-row'">Edit</button>
<form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
<button onclick="return confirm('Delete?')">Delete</button>
</form>
</td>
</tr>
<tr id="edit-%d" style="display:none">
<td colspan="8">
<form hx-put="/leads/%d/review" hx-target="#lead-%d" hx-swap="outerHTML">
<input type="text" name="name" value="%s" placeholder="Name">
<select name="service_interest">%s</select>
<select name="status">%s</select>
<button type="submit">Save</button>
<button type="button" onclick="document.getElementById('edit-%d').style.display='none'">Cancel</button>
</form>
</td>
</tr>`,
rowClass, l.LeadID,
htmlEscape(l.PhoneNormalized),
htmlEscape(l.Name),
htmlEscape(l.ServiceInterest),
htmlEscape(l.Status),
htmlEscape(l.PaymentStatus),
arrived,
lastContact,
l.LeadID,
l.LeadID, l.LeadID,
l.LeadID,
l.LeadID, l.LeadID,
htmlEscape(l.Name),
buildServiceOptions(services, l.ServiceInterest),
buildStatusOptions(statuses, l.Status),
l.LeadID,
)
if pendingCount > 0 {
fmt.Fprintf(buf, `<div class="mb-4"><a href="/leads/review" class="inline-flex items-center gap-2 px-3 py-2 bg-rose-400/5 border border-rose-400/15 rounded-lg text-xs text-rose-400 hover:bg-rose-400/10 transition-colors"><span class="w-1.5 h-1.5 rounded-full bg-rose-400 animate-pulse"></span>%d pending review</a></div>`, pendingCount)
}
fmt.Fprintf(w, `</tbody></table></body></html>`)
if len(leads) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>`, "No leads captured yet. Connect WhatsApp to start."))
} else {
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service", "Status", "Payment", "Arrived", "Last Contact", "Actions"}))
for _, l := range leads {
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
lastContact := time.Unix(l.LastContactAt, 0).Format("02/01 15:04")
rowClass := ""
if l.NeedsReview {
rowClass = `class="bg-amber-400/[0.03]"`
}
statusPill := statusPillHTML(l.Status)
fmt.Fprintf(buf, `<tr %s id="lead-%d">
<td class="font-mono text-xs text-zinc-300">%s</td>
<td class="font-medium text-zinc-200">%s %s</td>
<td class="text-zinc-400 text-xs">%s</td>
<td>%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td class="text-xs text-zinc-500">%s</td>
<td class="text-xs text-zinc-500">%s</td>
<td>
<div class="flex items-center gap-2">
<button onclick="document.getElementById('edit-%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
<button class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
</form>
</div>
</td>
</tr>`,
rowClass, l.LeadID,
htmlEscape(l.PhoneNormalized),
htmlEscape(l.Name),
map[bool]string{true: `<span class="ml-1.5 pill pill-rose text-[9px]">review</span>`, false: ""}[l.NeedsReview],
htmlEscape(l.ServiceInterest),
statusPill,
htmlEscape(l.PaymentStatus),
arrived,
lastContact,
l.LeadID,
l.LeadID, l.LeadID,
)
}
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
// Edit rows
for _, l := range leads {
fmt.Fprintf(buf, `
<div id="edit-%d" class="hidden mt-4 animate-slide-up">
<div class="card-industrial p-5 border-amber-400/10">
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Edit Lead #%d</h3>
<form hx-put="/leads/%d/review" hx-target="#lead-%d" hx-swap="outerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
<select name="service_interest" class="input-industrial text-xs">%s</select>
<select name="status" class="input-industrial text-xs">%s</select>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" onclick="document.getElementById('edit-%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, l.LeadID, l.LeadID, l.LeadID, l.LeadID, htmlEscape(l.Name), buildServiceOptions(services, l.ServiceInterest), buildStatusOptions(statuses, l.Status), l.LeadID)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "All Leads", "leads")
}
// DeleteLeadNew handles DELETE /leads/{id}
func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -330,8 +278,6 @@ func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
}
// serviceNames returns the list of service names for the review/all-leads dropdowns.
// It reads from the DB-backed services table first, then falls back to the
// hardcoded defaults so the dropdown is never empty on a fresh install.
func (a *App) serviceNames(clientID int64) []string {
rows, err := a.DB.Query(
"SELECT DISTINCT name FROM services WHERE client_id = ? ORDER BY name",
@@ -347,7 +293,6 @@ func (a *App) serviceNames(clientID int64) []string {
}
}
if len(names) > 0 {
// Ensure "Não especificou" is always first.
hasDefault := false
for _, n := range names {
if n == "Não especificou" {
@@ -361,7 +306,6 @@ func (a *App) serviceNames(clientID int64) []string {
return names
}
}
// Fallback: hardcoded defaults for a fresh install with no services yet.
return []string{
"Não especificou",
"Head Spa",
@@ -373,8 +317,6 @@ func (a *App) serviceNames(clientID int64) []string {
}
}
// --- rendering helpers -------------------------------------------------------
func buildServiceOptions(services []string, selected string) string {
out := ""
for _, s := range services {