Files
workspace/apps/go-crm/internal/handlers/dashboard.go
gabspereira 9c3bfd131d 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
2026-05-23 18:00:32 -03:00

229 lines
12 KiB
Go

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) {
accountID, err := a.getSession(r)
if err != nil {
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
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)
}
// WhatsApp status
waConnected := false
waPhone := ""
if a.WAConnector != nil && clientID > 0 {
if connected, _ := a.WAConnector.IsConnected(r.Context(), clientID); connected {
waConnected = true
} else {
var dbConnected int
a.DB.QueryRow("SELECT whatsapp_connected FROM clients WHERE client_id = ?", clientID).Scan(&dbConnected)
if dbConnected == 1 {
waConnected = true
}
}
var waNum string
a.DB.QueryRow("SELECT COALESCE(whatsapp_number,'') FROM clients WHERE client_id = ?", clientID).Scan(&waNum)
waPhone = waNum
}
// 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")
templates.WritePage(w, buf, "Dashboard", "dashboard")
}
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)
}