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:
@@ -2,7 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
)
|
||||
|
||||
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -14,72 +19,210 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var clientID int64
|
||||
a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
|
||||
|
||||
// Gather metrics
|
||||
var reviewCount int
|
||||
var totalLeads int
|
||||
var totalScheduled int
|
||||
var totalRevenue float64
|
||||
var totalSales int
|
||||
var totalCustomers int
|
||||
var totalServices int
|
||||
var pendingSchedules int
|
||||
|
||||
if clientID > 0 {
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ? AND needs_review = 1", clientID).Scan(&reviewCount)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ?", clientID).Scan(&totalLeads)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ? AND status = 'Agendou'", clientID).Scan(&totalScheduled)
|
||||
a.DB.QueryRow("SELECT COALESCE(SUM(amount),0), COUNT(*) FROM payments WHERE client_id = ? AND has_paid = 1", clientID).Scan(&totalRevenue, &totalSales)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM customers WHERE client_id IN (SELECT client_id FROM clients WHERE account_id = ?)", accountID).Scan(&totalCustomers)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM services WHERE client_id = ?", clientID).Scan(&totalServices)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM scheduling WHERE client_id = ? AND status = 'pending'", clientID).Scan(&pendingSchedules)
|
||||
}
|
||||
|
||||
waStatus := "disconnected"
|
||||
waStyle := "color:#dc3545"
|
||||
connectLink := ""
|
||||
if a.WAConnector != nil {
|
||||
// WhatsApp status
|
||||
waConnected := false
|
||||
waPhone := ""
|
||||
if a.WAConnector != nil && clientID > 0 {
|
||||
if connected, _ := a.WAConnector.IsConnected(r.Context(), clientID); connected {
|
||||
waStatus = "connected"
|
||||
waStyle = "color:#28a745"
|
||||
} else if clientID > 0 {
|
||||
// Fallback: check DB column when in-memory state not available.
|
||||
waConnected = true
|
||||
} else {
|
||||
var dbConnected int
|
||||
a.DB.QueryRow("SELECT whatsapp_connected FROM clients WHERE client_id = ?", clientID).Scan(&dbConnected)
|
||||
if dbConnected == 1 {
|
||||
waStatus = "connected"
|
||||
waStyle = "color:#28a745"
|
||||
} else {
|
||||
connectLink = fmt.Sprintf(` <a href="/leads/connect?client_id=%d" style="color:#28a745">Connect</a>`, clientID)
|
||||
waConnected = true
|
||||
}
|
||||
} else {
|
||||
connectLink = ` <a href="/clients" style="color:#28a745">Create client to connect</a>`
|
||||
}
|
||||
var waNum string
|
||||
a.DB.QueryRow("SELECT COALESCE(whatsapp_number,'') FROM clients WHERE client_id = ?", clientID).Scan(&waNum)
|
||||
waPhone = waNum
|
||||
}
|
||||
|
||||
badge := ""
|
||||
if reviewCount > 0 {
|
||||
badge = fmt.Sprintf(` <span style="background:#dc3545;color:#fff;border-radius:1rem;padding:0.15rem 0.5rem;font-size:0.8rem">%d</span>`, reviewCount)
|
||||
// Recent leads
|
||||
var recentLeads []db.Lead
|
||||
if clientID > 0 {
|
||||
leads, _ := db.ListAllLeads(a.DB, clientID, 5, 0)
|
||||
recentLeads = leads
|
||||
}
|
||||
|
||||
// Account name
|
||||
var accountName string
|
||||
a.DB.QueryRow("SELECT COALESCE(name,'User') FROM accounts WHERE account_id = ?", accountID).Scan(&accountName)
|
||||
|
||||
buf := templates.BufRender()
|
||||
|
||||
// KPI Row
|
||||
fmt.Fprintf(buf, `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">`)
|
||||
fmt.Fprint(buf, templates.KPICard("Total Leads", fmt.Sprintf("%d", totalLeads), "", "amber", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/>`, 1))
|
||||
fmt.Fprint(buf, templates.KPICard("Agendamentos", fmt.Sprintf("%d", totalScheduled), "", "emerald", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>`, 2))
|
||||
fmt.Fprint(buf, templates.KPICard("Revenue", fmt.Sprintf("R$ %.2f", totalRevenue), fmt.Sprintf("%d sales", totalSales), "sky", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2-1.343-2-3-2zm0 0v1m0 0v1m0-1h1m-1 0H9m12 0a2 2 0 012 2v4.5a2.5 2.5 0 01-2.5 2.5h-15a2.5 2.5 0 01-2.5-2.5V10a2 2 0 012-2h15z"/>`, 3))
|
||||
fmt.Fprint(buf, templates.KPICard("Pending Review", fmt.Sprintf("%d", reviewCount), "", "rose", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>`, 4))
|
||||
fmt.Fprintf(buf, `</div>`)
|
||||
|
||||
// Secondary KPIs
|
||||
fmt.Fprintf(buf, `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">`)
|
||||
secondaryKPIs := []struct{ label, value string }{
|
||||
{"Customers", fmt.Sprintf("%d", totalCustomers)},
|
||||
{"Services", fmt.Sprintf("%d", totalServices)},
|
||||
{"Pending Schedules", fmt.Sprintf("%d", pendingSchedules)},
|
||||
{"Conversion Rate", fmt.Sprintf("%.1f%%", conversionRate(totalScheduled, totalLeads))},
|
||||
}
|
||||
for _, kpi := range secondaryKPIs {
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="bg-zinc-900 border border-white/[0.06] rounded-lg p-3 animate-slide-up">
|
||||
<div class="text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1">%s</div>
|
||||
<div class="text-lg font-mono font-semibold text-zinc-200">%s</div>
|
||||
</div>`, kpi.label, kpi.value)
|
||||
}
|
||||
fmt.Fprintf(buf, `</div>`)
|
||||
|
||||
// WhatsApp Status Card
|
||||
waIcon := `<span class="w-2 h-2 rounded-full bg-rose-400 animate-pulse"></span>`
|
||||
waText := "Disconnected"
|
||||
waSub := "No active session"
|
||||
waAction := fmt.Sprintf(`<a href="/leads/connect?client_id=%d" class="btn-primary btn-sm">Connect</a>`, clientID)
|
||||
if waConnected {
|
||||
waIcon = `<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>`
|
||||
waText = "Connected"
|
||||
waSub = waPhone
|
||||
waAction = `<span class="pill pill-emerald">Online</span>`
|
||||
} else if clientID == 0 {
|
||||
waAction = `<a href="/clients" class="btn-ghost btn-sm">Create Client</a>`
|
||||
}
|
||||
if waPhone == "" && clientID > 0 {
|
||||
waSub = "Phone not configured"
|
||||
}
|
||||
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="card-industrial p-4 mb-6 border animate-slide-up stagger-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-400/5 border border-emerald-400/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-emerald-400" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.051-1.38l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.379-5.05c0-5.445 4.428-9.872 9.873-9.872 2.38 0 4.618.829 6.42 2.33a9.77 9.77 0 012.356 6.42c0 5.445-4.429 9.873-9.873 9.873m8.52-3.378a10.11 10.11 0 01-2.332 2.357 8.67 8.67 0 01-6.42 2.33c-5.28 0-9.58-4.298-9.58-9.578 0-5.28 4.3-9.58 9.58-9.58 2.38 0 4.618.83 6.42 2.33a10.11 10.11 0 012.332 2.357c1.152 1.522 1.756 3.347 1.756 5.28 0 1.934-.604 3.758-1.756 5.28"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
%s
|
||||
<span class="text-sm font-semibold text-zinc-200">%s</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 font-mono mt-0.5">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>%s</div>
|
||||
</div>
|
||||
</div>`, waIcon, waText, waSub, waAction)
|
||||
|
||||
// Recent Leads Section
|
||||
var recentHTML string
|
||||
if len(recentLeads) == 0 {
|
||||
recentHTML = string(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 yet. Connect WhatsApp to start capturing."))
|
||||
} else {
|
||||
recentHTML = string(templates.TableStart([]string{"Phone", "Name", "Service", "Status", "Arrived"}))
|
||||
for _, l := range recentLeads {
|
||||
statusPill := statusPillHTML(l.Status)
|
||||
reviewBadge := ""
|
||||
if l.NeedsReview {
|
||||
reviewBadge = `<span class="ml-2 pill pill-rose">review</span>`
|
||||
}
|
||||
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
||||
recentHTML += fmt.Sprintf(`<tr>
|
||||
<td class="font-mono text-xs">%s</td>
|
||||
<td>%s%s</td>
|
||||
<td class="text-zinc-400">%s</td>
|
||||
<td>%s</td>
|
||||
<td class="text-xs text-zinc-500">%s</td>
|
||||
</tr>`, htmlEscape(l.PhoneNormalized), htmlEscape(l.Name), reviewBadge, htmlEscape(l.ServiceInterest), statusPill, arrived)
|
||||
}
|
||||
recentHTML += string(templates.TableEnd())
|
||||
}
|
||||
|
||||
fmt.Fprint(buf, templates.SectionCard("Recent Leads", "Last 5 captured entries", template.HTML(recentHTML)))
|
||||
|
||||
// Quick actions
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-4 animate-slide-up stagger-3">
|
||||
<a href="/leads/review" class="card-industrial p-4 hover:bg-white/[0.02] transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded bg-rose-400/10 flex items-center justify-center group-hover:bg-rose-400/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-200">Review Queue</div>
|
||||
<div class="text-xs text-zinc-500">%d pending</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/report" class="card-industrial p-4 hover:bg-white/[0.02] transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded bg-sky-400/10 flex items-center justify-center group-hover:bg-sky-400/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6m10 0v-6a2 2 0 012-2h2a2 2 0 012 2v6m-6 0V7a2 2 0 012-2h2a2 2 0 012 2v12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-200">Monthly Report</div>
|
||||
<div class="text-xs text-zinc-500">Performance analytics</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/clients" class="card-industrial p-4 hover:bg-white/[0.02] transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded bg-amber-400/10 flex items-center justify-center group-hover:bg-amber-400/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-200">Clients</div>
|
||||
<div class="text-xs text-zinc-500">Manage your businesses</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`, reviewCount)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Dashboard</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 1.5rem; }
|
||||
nav a { margin-right: 1rem; text-decoration: none; color: #2c7be5; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
.wa-status { font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>CRM Dashboard</h1>
|
||||
<p class="wa-status">WhatsApp: <strong style="%s">%s</strong>%s</p>
|
||||
<nav>
|
||||
<a href="/leads/review">Review Queue%s</a>
|
||||
<a href="/leads/all">All Leads</a>
|
||||
<a href="/report">Monthly Report</a>
|
||||
<a href="/leads/keywords">Keyword Mapping</a>
|
||||
<a href="/clients">Clients</a>
|
||||
<a href="/services">Services</a>
|
||||
<a href="/scheduling">Scheduling</a>
|
||||
<a href="/payments">Payments</a>
|
||||
<a href="/auth/account">Account</a>
|
||||
<form method="POST" action="/auth/logout" style="display:inline">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
</body></html>`, waStyle, waStatus, connectLink, badge)
|
||||
templates.WritePage(w, buf, "Dashboard", "dashboard")
|
||||
}
|
||||
|
||||
// --- package-level shim kept for existing tests ---
|
||||
func conversionRate(scheduled, total int) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(scheduled) / float64(total) * 100
|
||||
}
|
||||
|
||||
func statusPillHTML(status string) string {
|
||||
color := "pill-zinc"
|
||||
switch status {
|
||||
case "Agendou":
|
||||
color = "pill-emerald"
|
||||
case "Cancelou":
|
||||
color = "pill-rose"
|
||||
case "Converteu":
|
||||
color = "pill-sky"
|
||||
case "Em negociacao":
|
||||
color = "pill-amber"
|
||||
}
|
||||
return fmt.Sprintf(`<span class="pill %s">%s</span>`, color, htmlEscape(status))
|
||||
}
|
||||
|
||||
// Dashboard is the package-level shim.
|
||||
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).Dashboard(w, r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user