Files
workspace/apps/go-crm/internal/handlers/leads.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

361 lines
14 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"go-crm/internal/db"
"go-crm/internal/templates"
"go-crm/internal/whatsapp"
"github.com/go-chi/chi/v5"
)
func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
search := r.URL.Query().Get("search")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit == 0 {
limit = 20
}
query := "SELECT cu.customer_id, cu.client_id, cu.name, cu.phone, cu.birth_date, cu.instagram, cu.created_at, COALESCE(cl.whatsapp_connected,0), COALESCE(cl.whatsapp_number,'') FROM customers cu JOIN clients cl ON cu.client_id = cl.client_id WHERE cl.account_id = ?"
args := []interface{}{accountID}
if search != "" {
query += " AND (cu.name LIKE ? OR cu.phone LIKE ?)"
searchPat := "%%" + search + "%%"
args = append(args, searchPat, searchPat)
}
query += " ORDER BY cu.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := a.DB.Query(query, args...)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var customers []db.Customer
for rows.Next() {
var c db.Customer
if err := rows.Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt, &c.WhatsAppConnected, &c.WhatsAppNumber); err != nil {
continue
}
customers = append(customers, c)
}
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Leads", "Search and manage customer leads"))
// Search
fmt.Fprintf(buf, `
<div class="card-industrial p-4 mb-6 animate-slide-up">
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML" class="flex gap-3">
<input type="text" name="search" placeholder="Search by name or phone..." value="%s" class="input-industrial max-w-md">
<button type="submit" class="btn-primary">Search</button>
</form>
</div>`, htmlEscape(search))
if len(customers) == 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 found. Try a different search or connect WhatsApp to capture leads."))
} else {
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up stagger-1"><div class="overflow-x-auto">`)
fmt.Fprint(buf, templates.TableStart([]string{"Name", "Phone", "Birth Date", "Instagram", "WhatsApp", "Actions"}))
for _, c := range customers {
waStatus := `<span class="text-zinc-600 text-xs">—</span>`
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
waStatus = fmt.Sprintf(`<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span><span class="font-mono text-xs">%s</span></span>`, htmlEscape(c.WhatsAppNumber))
}
fmt.Fprintf(buf, `<tr>
<td class="font-medium text-zinc-200">%s</td>
<td class="font-mono text-xs">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td>%s</td>
<td>
<div class="flex items-center gap-2">
<button type="button" onclick="document.getElementById('editLead%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/leads/%d" hx-target="closest tr" hx-swap="outerHTML" style="display:inline">
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
</form>
</div>
</td>
</tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), waStatus, c.CustomerID, c.CustomerID)
}
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Leads", "leads")
}
func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
r.ParseForm()
_, err := a.DB.Exec(
"UPDATE customers SET name = ?, phone = ?, birth_date = ?, instagram = ? WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id, accountID,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
a.ListLeads(w, r)
}
func (a *App) DeleteLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
_, err := a.DB.Exec(
"DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
id, accountID,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(""))
}
func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, clientID)
if err != nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Connect WhatsApp", fmt.Sprintf("Scan QR code to link %s", htmlEscape(client.Name))))
fmt.Fprintf(buf, `
<div class="max-w-lg mx-auto animate-slide-up">
<div class="card-industrial p-8 text-center">
<div id="qrcode" class="flex justify-center mb-6 min-h-[256px] items-center">
<div class="spinner"></div>
</div>
<p id="status" class="text-sm text-zinc-400 font-mono">Loading QR code...</p>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
var lastQR = '';
function pollQR() {
fetch('/leads/qr?client_id=%d')
.then(r => r.json())
.then(data => {
if (data.qr) {
if (data.qr !== lastQR) {
lastQR = data.qr;
document.getElementById('qrcode').innerHTML = '';
new QRCode(document.getElementById('qrcode'), { text: data.qr, width: 256, height: 256 });
}
document.getElementById('status').textContent = 'Scan with WhatsApp';
setTimeout(pollQR, 5000);
} else if (data.status === 'ready') {
document.getElementById('status').textContent = 'Connected! Verifying phone...';
document.getElementById('qrcode').innerHTML = '<div class="w-16 h-16 rounded-full bg-emerald-400/10 flex items-center justify-center mx-auto"><svg class="w-8 h-8 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>';
setTimeout(() => {
fetch('/leads/verify/%d')
.then(r => r.json())
.then(v => {
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
if (v.match === 'yes') {
document.getElementById('status').innerHTML = msg + ' matches client phone &mdash; <span class="text-emerald-400">verified</span>';
} else if (v.match === 'no') {
document.getElementById('status').innerHTML = msg + ' does NOT match client phone &mdash; <span class="text-rose-400">mismatch</span>';
} else {
document.getElementById('status').textContent = msg + ' (client phone unknown)';
}
})
.catch(() => {
document.getElementById('status').textContent = 'Connected! (could not verify phone)';
});
}, 1500);
} else if (data.status === 'error') {
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' retrying...';
setTimeout(pollQR, 8000);
} else {
document.getElementById('status').textContent = 'Status: ' + data.status;
setTimeout(pollQR, 5000);
}
})
.catch(err => {
document.getElementById('status').textContent = 'Connection error retrying...';
setTimeout(pollQR, 5000);
});
}
pollQR();
</script>`, client.ClientID, client.ClientID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Connect WhatsApp", "leads")
}
func jsonEscape(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func (a *App) LeadsQR(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"status":"error","error":"client not found"}`))
return
}
if a.WAConnector == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`))
return
}
connected, err := a.WAConnector.IsConnected(r.Context(), clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
return
}
if connected {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
log.Printf("QR: Starting Connect for client %d", clientID)
qrChan, err := a.WAConnector.Connect(context.Background(), clientID)
if err != nil {
log.Printf("QR: Connect returned error for client %d: %v", clientID, err)
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
return
}
timeout := time.After(30 * time.Second)
for {
select {
case <-timeout:
log.Printf("QR: Timeout waiting for client %d", clientID)
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape("timeout waiting for QR code") + `}`))
return
case frame, ok := <-qrChan:
if !ok {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":"connection closed"}`))
return
}
if frame.QR != "" {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"qr":` + jsonEscape(frame.QR) + `,"status":"waiting"}`))
return
}
if frame.State == whatsapp.StateConnected {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
return
}
if frame.State == whatsapp.StateFailed {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(frame.Error) + `}`))
return
}
}
}
}
func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(chi.URLParam(r, "client_id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"status":"error","error":"client not found"}`))
return
}
match := "unknown"
if client.WhatsAppNumber != "" && client.Phone != "" {
cleanWA := cleanPhone(client.WhatsAppNumber)
cleanClient := cleanPhone(client.Phone)
if cleanWA == cleanClient {
match = "yes"
} else {
match = "no"
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"status":"ok","wa_phone":"` + jsonEscape(client.WhatsAppNumber) + `","client_phone":"` + jsonEscape(client.Phone) + `","match":"` + match + `","client_name":"` + jsonEscape(client.Name) + `"}`))
}
func cleanPhone(s string) string {
s = strings.TrimPrefix(s, "+")
s = strings.TrimPrefix(s, "55")
return s
}
// --- package-level shims kept for existing tests ---
func ListLeads(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListLeads(w, r)
}
func UpdateLead(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).UpdateLead(w, r)
}
func DeleteLead(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).DeleteLead(w, r)
}
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).LeadsConnectPage(w, r)
}
func LeadsQR(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).LeadsQR(w, r)
}
func VerifyLead(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).VerifyLead(w, r)
}