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
229 lines
12 KiB
Go
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)
|
|
}
|