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
366 lines
12 KiB
Go
366 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-crm/internal/db"
|
|
"go-crm/internal/templates"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID := a.clientIDForAccount(accountID)
|
|
if clientID == 0 {
|
|
http.Error(w, "No client found for account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
|
|
leads, err := db.ListLeadsForReview(a.DB, clientID, limit, offset)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
|
services := a.serviceNames(clientID)
|
|
pendingCount := len(leads)
|
|
|
|
buf := templates.BufRender()
|
|
badge := ""
|
|
if pendingCount > 0 {
|
|
badge = fmt.Sprintf(`<span class="ml-2 pill pill-rose">%d</span>`, pendingCount)
|
|
}
|
|
fmt.Fprint(buf, templates.PageHeader("Review Queue", "Confirm or correct leads with unidentified service interest"))
|
|
fmt.Fprintf(buf, `<div class="mb-4">%s</div>`, badge)
|
|
|
|
if len(leads) == 0 {
|
|
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>`, "All caught up! No leads pending review."))
|
|
} else {
|
|
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
|
|
fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service Interest", "Status", "Arrived", "Actions"}))
|
|
for _, l := range leads {
|
|
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
|
fmt.Fprintf(buf, `<tr id="row-%d">
|
|
<td class="font-mono text-xs text-zinc-300">%s</td>
|
|
<td class="font-medium text-zinc-200">%s</td>
|
|
<td>
|
|
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML" class="flex flex-col gap-2">
|
|
<select name="service_interest" class="input-industrial text-xs">%s</select>
|
|
<select name="status" class="input-industrial text-xs">%s</select>
|
|
<input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
|
|
<button type="submit" class="btn-primary btn-sm self-start">Confirm</button>
|
|
</form>
|
|
</td>
|
|
<td><span class="pill pill-amber">%s</span></td>
|
|
<td class="text-xs text-zinc-500">%s</td>
|
|
<td>
|
|
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML" style="display:inline">
|
|
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this lead?')">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>`,
|
|
l.LeadID,
|
|
htmlEscape(l.PhoneNormalized),
|
|
htmlEscape(l.Name),
|
|
l.LeadID, l.LeadID,
|
|
buildServiceOptions(services, l.ServiceInterest),
|
|
buildStatusOptions(statuses, l.Status),
|
|
htmlEscape(l.Name),
|
|
htmlEscape(l.Status),
|
|
arrived,
|
|
l.LeadID, l.LeadID,
|
|
)
|
|
}
|
|
fmt.Fprint(buf, templates.TableEnd())
|
|
fmt.Fprintf(buf, `</div></div>`)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.WritePage(w, buf, "Review Queue", "review")
|
|
}
|
|
|
|
func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID := a.clientIDForAccount(accountID)
|
|
if clientID == 0 {
|
|
http.Error(w, "No client found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
leadID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
r.ParseForm()
|
|
|
|
lead, err := db.GetLeadByID(a.DB, clientID, leadID)
|
|
if err != nil {
|
|
http.Error(w, "Lead not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
lead.Name = r.FormValue("name")
|
|
lead.ServiceInterest = r.FormValue("service_interest")
|
|
lead.Status = r.FormValue("status")
|
|
lead.NeedsReview = false
|
|
|
|
if err := db.UpdateLead(a.DB, lead); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<tr id="row-%d" class="htmx-swapping" style="display:none"></tr>`, leadID)
|
|
}
|
|
|
|
func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID := a.clientIDForAccount(accountID)
|
|
if clientID == 0 {
|
|
http.Error(w, "No client found for account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
|
|
var leads []db.Lead
|
|
var err error
|
|
if a.LeadService != nil {
|
|
domainLeads, svcErr := a.LeadService.ListAllLeads(r.Context(), clientID, limit, offset)
|
|
if svcErr != nil {
|
|
http.Error(w, svcErr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
leads = make([]db.Lead, len(domainLeads))
|
|
for i, dl := range domainLeads {
|
|
leads[i] = db.Lead{
|
|
LeadID: dl.LeadID,
|
|
ClientID: dl.ClientID,
|
|
Name: dl.Name,
|
|
PhoneRaw: dl.PhoneRaw,
|
|
PhoneNormalized: dl.PhoneNormalized,
|
|
ServiceInterest: dl.ServiceInterest,
|
|
Status: dl.Status,
|
|
NeedsReview: dl.NeedsReview,
|
|
AppointmentDate: dl.AppointmentDate,
|
|
AppointmentTime: dl.AppointmentTime,
|
|
PaymentStatus: dl.PaymentStatus,
|
|
PaymentAmount: dl.PaymentAmount,
|
|
PaymentDate: dl.PaymentDate,
|
|
CreatedAt: dl.CreatedAt,
|
|
LastContactAt: dl.LastContactAt,
|
|
}
|
|
}
|
|
} else {
|
|
leads, err = db.ListAllLeads(a.DB, clientID, limit, offset)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID)
|
|
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
|
services := a.serviceNames(clientID)
|
|
|
|
buf := templates.BufRender()
|
|
fmt.Fprint(buf, templates.PageHeader("All Leads", "Complete lead database with filters and inline editing"))
|
|
|
|
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)
|
|
}
|
|
|
|
if len(leads) == 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 captured yet. Connect WhatsApp to start."))
|
|
} else {
|
|
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
|
|
fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service", "Status", "Payment", "Arrived", "Last Contact", "Actions"}))
|
|
for _, l := range leads {
|
|
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
|
lastContact := time.Unix(l.LastContactAt, 0).Format("02/01 15:04")
|
|
rowClass := ""
|
|
if l.NeedsReview {
|
|
rowClass = `class="bg-amber-400/[0.03]"`
|
|
}
|
|
statusPill := statusPillHTML(l.Status)
|
|
fmt.Fprintf(buf, `<tr %s id="lead-%d">
|
|
<td class="font-mono text-xs text-zinc-300">%s</td>
|
|
<td class="font-medium text-zinc-200">%s %s</td>
|
|
<td class="text-zinc-400 text-xs">%s</td>
|
|
<td>%s</td>
|
|
<td class="text-xs text-zinc-400">%s</td>
|
|
<td class="text-xs text-zinc-500">%s</td>
|
|
<td class="text-xs text-zinc-500">%s</td>
|
|
<td>
|
|
<div class="flex items-center gap-2">
|
|
<button onclick="document.getElementById('edit-%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
|
|
<form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
|
|
<button class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
|
|
</form>
|
|
</div>
|
|
</td>
|
|
</tr>`,
|
|
rowClass, l.LeadID,
|
|
htmlEscape(l.PhoneNormalized),
|
|
htmlEscape(l.Name),
|
|
map[bool]string{true: `<span class="ml-1.5 pill pill-rose text-[9px]">review</span>`, false: ""}[l.NeedsReview],
|
|
htmlEscape(l.ServiceInterest),
|
|
statusPill,
|
|
htmlEscape(l.PaymentStatus),
|
|
arrived,
|
|
lastContact,
|
|
l.LeadID,
|
|
l.LeadID, l.LeadID,
|
|
)
|
|
}
|
|
fmt.Fprint(buf, templates.TableEnd())
|
|
fmt.Fprintf(buf, `</div></div>`)
|
|
|
|
// Edit rows
|
|
for _, l := range leads {
|
|
fmt.Fprintf(buf, `
|
|
<div id="edit-%d" class="hidden mt-4 animate-slide-up">
|
|
<div class="card-industrial p-5 border-amber-400/10">
|
|
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Edit Lead #%d</h3>
|
|
<form hx-put="/leads/%d/review" hx-target="#lead-%d" hx-swap="outerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
|
|
<select name="service_interest" class="input-industrial text-xs">%s</select>
|
|
<select name="status" class="input-industrial text-xs">%s</select>
|
|
<div class="flex items-end gap-2">
|
|
<button type="submit" class="btn-primary btn-sm">Save</button>
|
|
<button type="button" onclick="document.getElementById('edit-%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>`, l.LeadID, l.LeadID, l.LeadID, l.LeadID, htmlEscape(l.Name), buildServiceOptions(services, l.ServiceInterest), buildStatusOptions(statuses, l.Status), l.LeadID)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.WritePage(w, buf, "All Leads", "leads")
|
|
}
|
|
|
|
func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
clientID := a.clientIDForAccount(accountID)
|
|
leadID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
a.DB.Exec("DELETE FROM leads WHERE lead_id = ? AND client_id = ?", leadID, clientID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(""))
|
|
}
|
|
|
|
// serviceNames returns the list of service names for the review/all-leads dropdowns.
|
|
func (a *App) serviceNames(clientID int64) []string {
|
|
rows, err := a.DB.Query(
|
|
"SELECT DISTINCT name FROM services WHERE client_id = ? ORDER BY name",
|
|
clientID,
|
|
)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
var names []string
|
|
for rows.Next() {
|
|
var n string
|
|
if rows.Scan(&n) == nil {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
hasDefault := false
|
|
for _, n := range names {
|
|
if n == "Não especificou" {
|
|
hasDefault = true
|
|
break
|
|
}
|
|
}
|
|
if !hasDefault {
|
|
names = append([]string{"Não especificou"}, names...)
|
|
}
|
|
return names
|
|
}
|
|
}
|
|
return []string{
|
|
"Não especificou",
|
|
"Head Spa",
|
|
"Massagem completa",
|
|
"Drenagem linfatica",
|
|
"Hydra Boost",
|
|
"Design Henna",
|
|
"Masculino",
|
|
}
|
|
}
|
|
|
|
func buildServiceOptions(services []string, selected string) string {
|
|
out := ""
|
|
for _, s := range services {
|
|
sel := ""
|
|
if s == selected {
|
|
sel = ` selected`
|
|
}
|
|
out += fmt.Sprintf(`<option value="%s"%s>%s</option>`, htmlEscape(s), sel, htmlEscape(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildStatusOptions(statuses []db.LeadStatus, selected string) string {
|
|
out := ""
|
|
for _, s := range statuses {
|
|
sel := ""
|
|
if s.StatusName == selected {
|
|
sel = ` selected`
|
|
}
|
|
out += fmt.Sprintf(`<option value="%s"%s>%s</option>`, htmlEscape(s.StatusName), sel, htmlEscape(s.StatusName))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, `"`, """)
|
|
return s
|
|
}
|
|
|
|
// --- package-level shims kept for existing tests ---
|
|
|
|
func LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).LeadReviewQueue(w, r)
|
|
}
|
|
func ConfirmLeadReview(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).ConfirmLeadReview(w, r)
|
|
}
|
|
func LeadAllList(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).LeadAllList(w, r)
|
|
}
|
|
func DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).DeleteLeadNew(w, r)
|
|
}
|