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

@@ -2,16 +2,16 @@ package handlers
import (
"fmt"
"html/template"
"net/http"
"strconv"
"go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5"
)
// LeadKeywordsPage renders the keyword mapping management screen.
// GET /leads/keywords
func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -35,63 +35,36 @@ func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID)
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Keyword Mapping", "Auto-detect services from WhatsApp messages"))
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)
}
// Service Keywords Section
keywordForm := fmt.Sprintf(`
<form hx-post="/leads/keywords" hx-target="#keywordTable" hx-swap="outerHTML" class="flex flex-wrap gap-3 mb-4">
<select name="service_name" class="input-industrial max-w-xs">%s</select>
<input type="text" name="keyword" placeholder="Keyword (e.g. massagem)" required class="input-industrial max-w-xs">
<button type="submit" class="btn-primary">Add Keyword</button>
</form>
%s`, buildServiceSelectOptions(services), renderKeywordTable(kws))
fmt.Fprint(buf, templates.SectionCard("Service Keywords", "Add keywords that trigger automatic service detection. Case and accent insensitive.", template.HTML(keywordForm)))
// Statuses Section
statusForm := fmt.Sprintf(`
<form hx-post="/leads/statuses" hx-target="#statusTable" hx-swap="outerHTML" class="flex flex-wrap gap-3 mb-4">
<input type="text" name="status_name" placeholder="New status name" required class="input-industrial max-w-xs">
<button type="submit" class="btn-primary">Add Status</button>
</form>
%s`, renderStatusTable(statuses))
fmt.Fprintf(buf, `<div class="mt-6">%s</div>`, templates.SectionCard("Lead Statuses", "Manage the status options available for leads.", template.HTML(statusForm)))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Keyword Mapping</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%%; margin-bottom: 2rem; }
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; }
.section { margin-top: 2rem; }
form.inline { display: inline; }
</style>
</head>
<body>
<h1>Keyword Mapping</h1>
<nav>
<a href="/">Home</a> |
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
<a href="/leads/all">All Leads</a> |
<a href="/report">Monthly Report</a>
</nav>
<br>
<h2>Service Keywords</h2>
<p>Add keywords that trigger automatic service detection. Case and accent insensitive.</p>
<form hx-post="/leads/keywords" hx-target="#keywordTable" hx-swap="outerHTML">
<select name="service_name">%s</select>
<input type="text" name="keyword" placeholder="Keyword (e.g. massagem)" required>
<button type="submit">Add Keyword</button>
</form>
<br><br>
%s
<div class="section">
<h2>Lead Statuses</h2>
<p>Manage the status options available for leads.</p>
<form hx-post="/leads/statuses" hx-target="#statusTable" hx-swap="outerHTML">
<input type="text" name="status_name" placeholder="New status name" required>
<button type="submit">Add Status</button>
</form>
<br><br>
%s
</div>
</body></html>`,
pendingCount,
buildServiceSelectOptions(services),
renderKeywordTable(kws),
renderStatusTable(statuses),
)
templates.WritePage(w, buf, "Keyword Mapping", "")
}
// AddServiceKeyword adds a new keyword mapping.
// POST /leads/keywords
func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -112,8 +85,6 @@ func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, renderKeywordTable(kws))
}
// DeleteServiceKeywordHandler removes a keyword mapping.
// DELETE /leads/keywords/{id}
func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -128,8 +99,6 @@ func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request
fmt.Fprint(w, renderKeywordTable(kws))
}
// AddLeadStatusHandler adds a new custom lead status.
// POST /leads/statuses
func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -149,8 +118,6 @@ func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, renderStatusTable(statuses))
}
// DeleteLeadStatusHandler removes a lead status.
// DELETE /leads/statuses/{id}
func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
@@ -165,13 +132,11 @@ func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, renderStatusTable(statuses))
}
// --- rendering helpers -------------------------------------------------------
func buildServiceSelectOptions(services []string) string {
out := ""
for _, s := range services {
if s == "Não especificou" {
continue // don't map keywords to the fallback
continue
}
out += fmt.Sprintf(`<option value="%s">%s</option>`, htmlEscape(s), htmlEscape(s))
}
@@ -179,51 +144,47 @@ func buildServiceSelectOptions(services []string) string {
}
func renderKeywordTable(kws []db.ServiceKeyword) string {
out := `<table id="keywordTable">
<thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead>
<tbody>`
out := `<div id="keywordTable"><table class="data-table"><thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead><tbody>`
if len(kws) == 0 {
out += `<tr><td colspan="3">No keywords defined.</td></tr>`
out += `<tr><td colspan="3" class="text-zinc-500 text-sm py-4">No keywords defined.</td></tr>`
}
for _, kw := range kws {
out += fmt.Sprintf(`
<tr>
<td>%s</td>
<td>%s</td>
<td class="text-zinc-200">%s</td>
<td class="font-mono text-xs text-amber-400">%s</td>
<td>
<form hx-delete="/leads/keywords/%d" hx-target="#keywordTable" hx-swap="outerHTML" style="display:inline">
<button type="submit">Remove</button>
<button type="submit" class="btn-danger btn-sm">Remove</button>
</form>
</td>
</tr>`, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID)
}
out += `</tbody></table>`
out += `</tbody></table></div>`
return out
}
func renderStatusTable(statuses []db.LeadStatus) string {
out := `<table id="statusTable">
<thead><tr><th>Status</th><th>Actions</th></tr></thead>
<tbody>`
out := `<div id="statusTable"><table class="data-table"><thead><tr><th>Status</th><th>Actions</th></tr></thead><tbody>`
if len(statuses) == 0 {
out += `<tr><td colspan="2">No statuses defined.</td></tr>`
out += `<tr><td colspan="2" class="text-zinc-500 text-sm py-4">No statuses defined.</td></tr>`
}
for _, s := range statuses {
out += fmt.Sprintf(`
<tr>
<td>%s</td>
<td><span class="pill pill-zinc">%s</span></td>
<td>
<form hx-delete="/leads/statuses/%d" hx-target="#statusTable" hx-swap="outerHTML" style="display:inline">
<button type="submit">Remove</button>
<button type="submit" class="btn-danger btn-sm">Remove</button>
</form>
</td>
</tr>`, htmlEscape(s.StatusName), s.StatusID)
}
out += `</tbody></table>`
out += `</tbody></table></div>`
return out
}
// --- package-level shims kept for existing tests ---
// --- package-level shims ---
func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r)