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:
2026-05-23 18:00:32 -03:00
parent 744868caa1
commit 9c3bfd131d
16 changed files with 1901 additions and 791 deletions

View File

@@ -2,9 +2,12 @@ package handlers
import ( import (
"database/sql" "database/sql"
"fmt"
"net/http" "net/http"
"time" "time"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -13,24 +16,38 @@ import (
var DB *sql.DB var DB *sql.DB
func (a *App) SignupPage(w http.ResponseWriter, r *http.Request) { func (a *App) SignupPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") content := fmt.Sprintf(`
w.Write([]byte(`<!DOCTYPE html> <div class="min-h-[60vh] flex items-center justify-center">
<html lang="en"> <div class="w-full max-w-sm">
<head> <div class="text-center mb-8">
<meta charset="UTF-8"> <div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-amber-400/10 border border-amber-400/20 mb-4">
<title>Sign Up</title> <svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</head> </div>
<body> <h1 class="text-2xl font-bold text-zinc-100 tracking-tight">go-crm</h1>
<h1>Sign Up</h1> <p class="text-sm text-zinc-500 mt-1">Create your account</p>
<form method="POST" action="/auth/signup"> </div>
<input type="email" name="email" placeholder="Email" required> <div class="card-industrial p-6">
<input type="text" name="name" placeholder="Name" required> <form method="POST" action="/auth/signup" class="space-y-4">
<input type="password" name="password" placeholder="Password" required> <div>
<button type="submit">Sign Up</button> <label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Email</label>
<input type="email" name="email" required class="input-industrial" placeholder="you@company.com">
</div>
<div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Name</label>
<input type="text" name="name" required class="input-industrial" placeholder="Your name">
</div>
<div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Password</label>
<input type="password" name="password" required class="input-industrial" placeholder="Min 8 characters">
</div>
<button type="submit" class="btn-primary w-full justify-center mt-2">Create Account</button>
</form> </form>
<p>Already have an account? <a href="/auth/login">Login</a></p> </div>
</body> <p class="text-center text-xs text-zinc-500 mt-6">Already have an account? <a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Sign in</a></p>
</html>`)) </div>
</div>`)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WriteHTMLPage(w, "Sign Up", "", content)
} }
func (a *App) Signup(w http.ResponseWriter, r *http.Request) { func (a *App) Signup(w http.ResponseWriter, r *http.Request) {
@@ -50,7 +67,6 @@ func (a *App) Signup(w http.ResponseWriter, r *http.Request) {
return return
} }
// Use a transaction so account + client + session are atomic.
tx, err := a.DB.Begin() tx, err := a.DB.Begin()
if err != nil { if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError) http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
@@ -101,23 +117,34 @@ func (a *App) Signup(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) { func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") content := fmt.Sprintf(`
w.Write([]byte(`<!DOCTYPE html> <div class="min-h-[60vh] flex items-center justify-center">
<html lang="en"> <div class="w-full max-w-sm">
<head> <div class="text-center mb-8">
<meta charset="UTF-8"> <div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-amber-400/10 border border-amber-400/20 mb-4">
<title>Login</title> <svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</head> </div>
<body> <h1 class="text-2xl font-bold text-zinc-100 tracking-tight">go-crm</h1>
<h1>Login</h1> <p class="text-sm text-zinc-500 mt-1">Sign in to your workspace</p>
<form method="POST" action="/auth/login"> </div>
<input type="email" name="email" placeholder="Email" required> <div class="card-industrial p-6">
<input type="password" name="password" placeholder="Password" required> <form method="POST" action="/auth/login" class="space-y-4">
<button type="submit">Login</button> <div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Email</label>
<input type="email" name="email" required class="input-industrial" placeholder="you@company.com">
</div>
<div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Password</label>
<input type="password" name="password" required class="input-industrial" placeholder="Enter your password">
</div>
<button type="submit" class="btn-primary w-full justify-center mt-2">Sign In</button>
</form> </form>
<p>Don't have an account? <a href="/auth/signup">Sign Up</a></p> </div>
</body> <p class="text-center text-xs text-zinc-500 mt-6">Don't have an account? <a href="/auth/signup" class="text-amber-400 hover:text-amber-300 transition-colors">Create one</a></p>
</html>`)) </div>
</div>`)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WriteHTMLPage(w, "Sign In", "", content)
} }
func (a *App) Login(w http.ResponseWriter, r *http.Request) { func (a *App) Login(w http.ResponseWriter, r *http.Request) {
@@ -173,14 +200,32 @@ func (a *App) AccountPage(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") content := fmt.Sprintf(`
w.Write([]byte(`<!DOCTYPE html><html><head><title>Account</title></head><body><h1>Account Settings</h1><form method="POST" action="/auth/account"> %s
<p>Name: <input type="text" name="name" value="` + name + `"></p> <div class="max-w-lg animate-slide-up">
<p>Email: <input type="email" value="` + email + `" disabled></p> <div class="card-industrial p-6">
<p>New Password: <input type="password" name="password" placeholder="Leave blank to keep current"></p> <form method="POST" action="/auth/account" class="space-y-4">
<button type="submit">Update</button> <div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Name</label>
<input type="text" name="name" value="%s" class="input-industrial">
</div>
<div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Email</label>
<input type="email" value="%s" disabled class="input-industrial opacity-50 cursor-not-allowed">
</div>
<div>
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">New Password</label>
<input type="password" name="password" class="input-industrial" placeholder="Leave blank to keep current">
</div>
<div class="pt-2">
<button type="submit" class="btn-primary">Update Account</button>
</div>
</form> </form>
<a href="/">Back to Dashboard</a></body></html>`)) </div>
</div>`, templates.PageHeader("Account Settings", "Manage your profile and security"), htmlEscape(name), htmlEscape(email))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WriteHTMLPage(w, "Account", "", content)
} }
func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -28,75 +30,94 @@ func (a *App) ListClients(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html> actions := `<button type="button" onclick="document.getElementById('clientForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Client</button>`
<html> fmt.Fprint(buf, templates.PageHeader("Clients", "Manage your business clients", actions))
<head>
<title>Clients</title> // Add form
<script src="https://unpkg.com/htmx.org@1.9.10"></script> fmt.Fprintf(buf, `
<style> <div id="clientForm" class="hidden mb-6 animate-slide-up">
.edit-form { display:none; margin-top:0.5rem; padding:0.5rem; border:1px solid #ccc; } <div class="card-industrial p-5">
.edit-row { display:none; } <h3 class="text-sm font-semibold text-zinc-200 mb-4">New Client</h3>
</style> <form hx-post="/clients" hx-target="#clientList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
</head> <input type="text" name="name" placeholder="Name" required class="input-industrial">
<body> <input type="tel" name="phone" placeholder="Phone" class="input-industrial">
<h1>Clients</h1> <input type="email" name="email" placeholder="Email" class="input-industrial">
<button class="btn" onclick="document.getElementById('clientForm').style.display='block'">Add Client</button> <input type="text" name="address" placeholder="Address" class="input-industrial">
<div id="clientForm" style="display:none; margin-top:1rem;"> <textarea name="notes" placeholder="Notes" class="input-industrial sm:col-span-2 lg:col-span-2" rows="2"></textarea>
<form hx-post="/clients" hx-target="#clientList" hx-swap="innerHTML"> <div class="flex items-end">
<input type="text" name="name" placeholder="Name" required> <button type="submit" class="btn-primary">Save Client</button>
<input type="tel" name="phone" placeholder="Phone"> </div>
<input type="email" name="email" placeholder="Email">
<input type="text" name="address" placeholder="Address">
<textarea name="notes" placeholder="Notes"></textarea>
<button type="submit">Add Client</button>
</form> </form>
</div> </div>
<table> </div>`)
<thead>
<tr><th>Name</th><th>Phone</th><th>WhatsApp</th><th>Actions</th></tr> if len(clients) == 0 {
</thead> fmt.Fprint(buf, templates.EmptyState(`<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"/>`, "No clients found. Add your first client to get started."))
<tbody id="clientList"> } 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", "WhatsApp", "Created", "Actions"}))
for _, c := range clients { for _, c := range clients {
var whatsappCell string var whatsappCell string
if c.WhatsAppNumber == "" {
whatsappCell = fmt.Sprintf(`<a href="/leads/connect?client_id=%d" class="btn-ghost btn-sm">Connect</a>`, c.ClientID)
} else {
connected := false connected := false
if c.WhatsAppNumber != "" && a.WAConnector != nil { if a.WAConnector != nil {
connected, _ = a.WAConnector.IsConnected(r.Context(), c.ClientID) connected, _ = a.WAConnector.IsConnected(r.Context(), c.ClientID)
} }
if connected { if connected {
whatsappCell = c.WhatsAppNumber + ` <span style="color:#28a745">&#9679;</span>` whatsappCell = 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 text-zinc-300">%s</span></span>`, htmlEscape(c.WhatsAppNumber))
} else if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" { } else if c.WhatsAppConnected == 1 {
whatsappCell = c.WhatsAppNumber + ` <span style="color:#dc3545">&#9675;</span> <a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Reconnect</a>` whatsappCell = fmt.Sprintf(`<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span><span class="font-mono text-xs text-zinc-300">%s</span><a href="/leads/connect?client_id=%d" class="text-[10px] text-amber-400 hover:underline">reconnect</a></span>`, htmlEscape(c.WhatsAppNumber), c.ClientID)
} else { } else {
whatsappCell = `<a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Connect</a>` whatsappCell = fmt.Sprintf(`<a href="/leads/connect?client_id=%d" class="btn-ghost btn-sm">Connect</a>`, c.ClientID)
} }
w.Write([]byte(`<tr> }
<td>` + c.Name + `</td> created := time.Unix(c.CreatedAt, 0).Format("02/01/2006")
<td>` + c.Phone + `</td> fmt.Fprintf(buf, `<tr>
<td>` + whatsappCell + `</td> <td class="font-medium text-zinc-200">%s</td>
<td class="font-mono text-xs">%s</td>
<td>%s</td>
<td class="text-xs text-zinc-500">%s</td>
<td> <td>
<a href="/clients/` + strconv.FormatInt(c.ClientID, 10) + `">View</a> <div class="flex items-center gap-2">
<button type="button" onclick="document.getElementById('editForm` + strconv.FormatInt(c.ClientID, 10) + `').style.display='block'">Edit</button> <a href="/clients/%d" class="btn-ghost btn-sm">View</a>
<form method="DELETE" style="display:inline" hx-delete="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="closest tr"> <button type="button" onclick="document.getElementById('editForm%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<button type="submit">Delete</button> <form hx-delete="/clients/%d" hx-target="closest tr" hx-swap="outerHTML" style="display:inline">
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this client?')">Delete</button>
</form> </form>
<tr id="editForm` + strconv.FormatInt(c.ClientID, 10) + `" class="edit-row"><td colspan="4"> </div>
<form hx-put="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="#clientList" hx-swap="innerHTML">
<input type="text" name="name" value="` + c.Name + `">
<input type="tel" name="phone" value="` + c.Phone + `">
<input type="email" name="email" value="` + c.Email + `">
<input type="text" name="address" value="` + c.Address + `">
<textarea name="notes">` + c.Notes + `</textarea>
<button type="submit">Save</button>
</form>
</td></tr>
</td> </td>
</tr>`)) </tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), whatsappCell, created, c.ClientID, c.ClientID, c.ClientID)
} }
w.Write([]byte(`</tbody></table> fmt.Fprint(buf, templates.TableEnd())
<p><a href="/">Back to Home</a></p> fmt.Fprintf(buf, `</div></div>`)
</body></html>`))
// Edit forms (rendered below table, toggled via JS)
for _, c := range clients {
fmt.Fprintf(buf, `
<div id="editForm%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-4">Edit %s</h3>
<form hx-put="/clients/%d" hx-target="#clientList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<input type="text" name="name" value="%s" class="input-industrial">
<input type="tel" name="phone" value="%s" class="input-industrial">
<input type="email" name="email" value="%s" class="input-industrial">
<input type="text" name="address" value="%s" class="input-industrial">
<textarea name="notes" class="input-industrial sm:col-span-2 lg:col-span-2" rows="2">%s</textarea>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" onclick="document.getElementById('editForm%d').classList.add('hidden')" class="btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>`, c.ClientID, htmlEscape(c.Name), c.ClientID, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.Email), htmlEscape(c.Address), htmlEscape(c.Notes), c.ClientID)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Clients", "clients")
} }
func (a *App) CreateClient(w http.ResponseWriter, r *http.Request) { func (a *App) CreateClient(w http.ResponseWriter, r *http.Request) {
@@ -121,7 +142,6 @@ func (a *App) CreateClient(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Refresh", "true") w.Header().Set("HX-Refresh", "true")
} }
@@ -138,43 +158,76 @@ func (a *App) ViewClient(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader(htmlEscape(client.Name), "Client details and WhatsApp connection"))
var whatsappSection string waSection := ""
if client.WhatsAppNumber != "" { if client.WhatsAppNumber != "" {
connected := false connected := false
if a.WAConnector != nil { if a.WAConnector != nil {
connected, _ = a.WAConnector.IsConnected(r.Context(), client.ClientID) connected, _ = a.WAConnector.IsConnected(r.Context(), client.ClientID)
} }
status := "Not connected" statusPill := `<span class="pill pill-rose">Disconnected</span>`
style := "color:#999"
if connected { if connected {
status = "Connected" statusPill = `<span class="pill pill-emerald">Connected</span>`
style = "color:#28a745"
} else if client.WhatsAppConnected == 1 { } else if client.WhatsAppConnected == 1 {
status = "Disconnected (was connected)" statusPill = `<span class="pill pill-amber">Stale</span>`
style = "color:#dc3545"
} }
whatsappSection = ` waSection = fmt.Sprintf(`
<p>WhatsApp: <strong>` + client.WhatsAppNumber + `</strong> <span style="` + style + `">(` + status + `)</span></p> <div class="card-industrial p-5 mb-6">
<p><a href="/leads/connect?client_id=` + strconv.FormatInt(client.ClientID, 10) + `">Reconnect WhatsApp</a></p>` <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-zinc-200">WhatsApp</h3>
%s
</div>
<div class="font-mono text-sm text-zinc-300">%s</div>
<div class="mt-4">
<a href="/leads/connect?client_id=%d" class="btn-ghost btn-sm">%s</a>
</div>
</div>`, statusPill, htmlEscape(client.WhatsAppNumber), client.ClientID, map[bool]string{true: "Reconnect", false: "Reconnect"}[true])
} else { } else {
whatsappSection = ` waSection = fmt.Sprintf(`
<p>WhatsApp: Not configured <a href="/leads/connect?client_id=` + strconv.FormatInt(client.ClientID, 10) + `">Connect</a></p>` <div class="card-industrial p-5 mb-6">
<h3 class="text-sm font-semibold text-zinc-200 mb-2">WhatsApp</h3>
<p class="text-sm text-zinc-500 mb-4">No WhatsApp number configured for this client.</p>
<a href="/leads/connect?client_id=%d" class="btn-primary btn-sm">Connect WhatsApp</a>
</div>`, client.ClientID)
} }
w.Write([]byte(`<!DOCTYPE html> fmt.Fprintf(buf, `
<html> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-slide-up">
<head></head> <div class="lg:col-span-2">
<body> <div class="card-industrial p-5">
<h1>` + client.Name + `</h1> <h3 class="text-sm font-semibold text-zinc-200 mb-4">Details</h3>
<p>Client ID: ` + strconv.FormatInt(client.ClientID, 10) + `</p> <div class="space-y-3">
<p>Phone: ` + client.Phone + `</p> <div class="flex justify-between py-2 border-b border-white/[0.04]">
<p>Email: ` + client.Email + `</p> <span class="text-xs text-zinc-500 font-mono uppercase">Phone</span>
<p>Address: ` + client.Address + `</p> <span class="text-sm text-zinc-300 font-mono">%s</span>
<p>Notes: ` + client.Notes + `</p>` + whatsappSection + ` </div>
<a href="/clients">Back</a> <div class="flex justify-between py-2 border-b border-white/[0.04]">
</body></html>`)) <span class="text-xs text-zinc-500 font-mono uppercase">Email</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-2 border-b border-white/[0.04]">
<span class="text-xs text-zinc-500 font-mono uppercase">Address</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-2">
<span class="text-xs text-zinc-500 font-mono uppercase">Notes</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
</div>
</div>
</div>
<div>
%s
</div>
</div>
<div class="mt-4">
<a href="/clients" class="btn-ghost btn-sm">Back to Clients</a>
</div>`, htmlEscape(client.Phone), htmlEscape(client.Email), htmlEscape(client.Address), htmlEscape(client.Notes), waSection)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, client.Name, "clients")
} }
func (a *App) UpdateClient(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateClient(w http.ResponseWriter, r *http.Request) {
@@ -217,7 +270,7 @@ func (a *App) DeleteClient(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("OK")) w.Write([]byte(""))
} }
// --- package-level shims kept for existing tests --- // --- package-level shims kept for existing tests ---

View File

@@ -1,12 +1,14 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"go-crm/config" "go-crm/config"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -38,15 +40,76 @@ func (a *App) ListCustomers(w http.ResponseWriter, r *http.Request) {
var clientOptions string var clientOptions string
for _, c := range clients { for _, c := range clients {
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` clientOptions += fmt.Sprintf(`<option value="%d">%s</option>`, c.ClientID, htmlEscape(c.Name))
}
buf := templates.BufRender()
actions := `<button type="button" onclick="document.getElementById('customerForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Customer</button>`
fmt.Fprint(buf, templates.PageHeader("Customers", "Your customer database", actions))
fmt.Fprintf(buf, `
<div id="customerForm" class="hidden mb-6 animate-slide-up">
<div class="card-industrial p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-4">New Customer</h3>
<form hx-post="/customers" hx-target="#customerList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<input type="text" name="name" placeholder="Name" required class="input-industrial">
<input type="tel" name="phone" placeholder="Phone" class="input-industrial">
<input type="date" name="birth_date" class="input-industrial">
<input type="text" name="instagram" placeholder="Instagram" class="input-industrial">
<select name="client_id" required class="input-industrial"><option value="">Select Client</option>%s</select>
<div class="flex items-end"><button type="submit" class="btn-primary">Save</button></div>
</form>
</div>
</div>`, clientOptions)
if len(customers) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>`, "No customers found. Add your first customer."))
} 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", "Actions"}))
for _, c := range customers {
fmt.Fprintf(buf, `<tr>
<td class="font-medium text-zinc-200">%s</td>
<td class="font-mono text-xs text-zinc-300">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td>
<div class="flex items-center gap-2">
<a href="/customers/%d" class="btn-ghost btn-sm">View</a>
<button type="button" onclick="document.getElementById('editCust%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/customers/%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), c.CustomerID, c.CustomerID, c.CustomerID)
}
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
for _, c := range customers {
fmt.Fprintf(buf, `
<div id="editCust%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 %s</h3>
<form hx-put="/customers/%d" hx-target="#customerList" hx-swap="innerHTML" 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">
<input type="tel" name="phone" value="%s" class="input-industrial text-xs">
<input type="date" name="birth_date" value="%s" class="input-industrial text-xs">
<input type="text" name="instagram" value="%s" class="input-industrial text-xs">
<select name="client_id" class="input-industrial text-xs"><option value="%d">%s</option>%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('editCust%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, c.CustomerID, htmlEscape(c.Name), c.CustomerID, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), c.ClientID, htmlEscape(c.Name), clientOptions, c.CustomerID)
}
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script><style>.edit-form{display:none;margin-top:0.5rem;padding:0.5rem;border:1px solid #ccc}</style></head><body><h1>Customers</h1><button class="btn" onclick="document.getElementById('customerForm').style.display='block'">Add Customer</button><div id="customerForm" style="display:none; margin-top:1rem;"><form hx-post="/customers" hx-target="#customerList"><input type="text" name="name" placeholder="Name" required><input type="tel" name="phone" placeholder="Phone"><input type="date" name="birth_date" placeholder="Birth Date"><input type="text" name="instagram" placeholder="Instagram"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><button type="submit">Add</button></form></div><table><thead><tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>Actions</th></tr></thead><tbody id="customerList">`)) templates.WritePage(w, buf, "Customers", "customers")
for _, c := range customers {
w.Write([]byte(`<tr><td>` + c.Name + `</td><td>` + c.Phone + `</td><td>` + c.BirthDate + `</td><td>` + c.Instagram + `</td><td><a href="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `">View</a><button type="button" onclick="document.getElementById('editCust` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='block'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editCust` + strconv.FormatInt(c.CustomerID, 10) + `" style="display:none"><td colspan="5"><form hx-put="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#customerList" hx-swap="innerHTML"><input type="text" name="name" value="` + c.Name + `"><input type="tel" name="phone" value="` + c.Phone + `"><input type="date" name="birth_date" value="` + c.BirthDate + `"><input type="text" name="instagram" value="` + c.Instagram + `"><select name="client_id"><option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` + clientOptions + `</select><button type="submit">Save</button></form></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
} }
func (a *App) CreateCustomer(w http.ResponseWriter, r *http.Request) { func (a *App) CreateCustomer(w http.ResponseWriter, r *http.Request) {
@@ -134,8 +197,33 @@ func (a *App) ViewCustomer(w http.ResponseWriter, r *http.Request) {
return return
} }
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader(htmlEscape(c.Name), "Customer profile"))
fmt.Fprintf(buf, `
<div class="max-w-lg animate-slide-up">
<div class="card-industrial p-6">
<div class="space-y-3">
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Phone</span>
<span class="text-sm font-mono text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Birth Date</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-3">
<span class="text-xs font-mono uppercase text-zinc-500">Instagram</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
</div>
<div class="mt-6">
<a href="/customers" class="btn-ghost btn-sm">Back</a>
</div>
</div>
</div>`, htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><body><h1>` + c.Name + `</h1><p>Phone: ` + c.Phone + `</p><p>Birth Date: ` + c.BirthDate + `</p><p>Instagram: ` + c.Instagram + `</p><a href="/customers">Back</a></body></html>`)) templates.WritePage(w, buf, c.Name, "customers")
} }
func (a *App) UpdateCustomer(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
@@ -165,7 +253,7 @@ func (a *App) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
a.DB.Exec("DELETE FROM customers WHERE customer_id = ?", id) a.DB.Exec("DELETE FROM customers WHERE customer_id = ?", id)
} }
// --- package-level shims kept for existing tests --- // --- package-level shims ---
func ListCustomers(w http.ResponseWriter, r *http.Request) { func ListCustomers(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListCustomers(w, r) (&App{DB: DB, WAConnector: WAConnector}).ListCustomers(w, r)

View File

@@ -2,7 +2,12 @@ package handlers
import ( import (
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"time"
"go-crm/internal/db"
"go-crm/internal/templates"
) )
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) { 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 var clientID int64
a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID) a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
// Gather metrics
var reviewCount int 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 { 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 = ? 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" // WhatsApp status
waStyle := "color:#dc3545" waConnected := false
connectLink := "" waPhone := ""
if a.WAConnector != nil { if a.WAConnector != nil && clientID > 0 {
if connected, _ := a.WAConnector.IsConnected(r.Context(), clientID); connected { if connected, _ := a.WAConnector.IsConnected(r.Context(), clientID); connected {
waStatus = "connected" waConnected = true
waStyle = "color:#28a745" } else {
} else if clientID > 0 {
// Fallback: check DB column when in-memory state not available.
var dbConnected int var dbConnected int
a.DB.QueryRow("SELECT whatsapp_connected FROM clients WHERE client_id = ?", clientID).Scan(&dbConnected) a.DB.QueryRow("SELECT whatsapp_connected FROM clients WHERE client_id = ?", clientID).Scan(&dbConnected)
if dbConnected == 1 { if dbConnected == 1 {
waStatus = "connected" waConnected = true
waStyle = "color:#28a745"
} else {
connectLink = fmt.Sprintf(` <a href="/leads/connect?client_id=%d" style="color:#28a745">Connect</a>`, clientID)
} }
} 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 := "" // Recent leads
if reviewCount > 0 { var recentLeads []db.Lead
badge = fmt.Sprintf(` <span style="background:#dc3545;color:#fff;border-radius:1rem;padding:0.15rem 0.5rem;font-size:0.8rem">%d</span>`, reviewCount) 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") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html> templates.WritePage(w, buf, "Dashboard", "dashboard")
<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)
} }
// --- 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) { func Dashboard(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).Dashboard(w, r) (&App{DB: DB, WAConnector: WAConnector}).Dashboard(w, r)
} }

View File

@@ -75,8 +75,8 @@ func TestDashboardHidesConnectLinkWhenDBConnected(t *testing.T) {
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "connected") { if !strings.Contains(body, "Connected") {
t.Error("expected dashboard to show 'connected' status from DB column") t.Error("expected dashboard to show 'Connected' status from DB column")
} }
if strings.Contains(body, "/leads/connect") { if strings.Contains(body, "/leads/connect") {
@@ -138,8 +138,8 @@ func TestDashboardShowsConnectLinkWhenDisconnected(t *testing.T) {
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "disconnected") { if !strings.Contains(body, "Disconnected") {
t.Error("expected dashboard to show 'disconnected' status") t.Error("expected dashboard to show 'Disconnected' status")
} }
if !strings.Contains(body, "/clients") { if !strings.Contains(body, "/clients") {
@@ -212,8 +212,8 @@ func TestDashboardShowsConnectedStatus(t *testing.T) {
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "connected") { if !strings.Contains(body, "Connected") {
t.Error("expected dashboard to show 'connected' status") t.Error("expected dashboard to show 'Connected' status")
} }
if strings.Contains(body, "/leads/connect") { if strings.Contains(body, "/leads/connect") {
@@ -281,8 +281,8 @@ func TestDashboardShowsConnectLinkWithClientID(t *testing.T) {
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "disconnected") { if !strings.Contains(body, "Disconnected") {
t.Error("expected dashboard to show 'disconnected' status") t.Error("expected dashboard to show 'Disconnected' status")
} }
if !strings.Contains(body, "/leads/connect?client_id=") { if !strings.Contains(body, "/leads/connect?client_id=") {

View File

@@ -2,16 +2,16 @@ package handlers
import ( import (
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"strconv" "strconv"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// LeadKeywordsPage renders the keyword mapping management screen.
// GET /leads/keywords
func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) { func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -35,63 +35,36 @@ func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID) pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID)
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Keyword Mapping", "Auto-detect services from WhatsApp messages"))
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)
}
// Service Keywords Section
keywordForm := fmt.Sprintf(`
<form hx-post="/leads/keywords" hx-target="#keywordTable" hx-swap="outerHTML" class="flex flex-wrap gap-3 mb-4">
<select name="service_name" class="input-industrial max-w-xs">%s</select>
<input type="text" name="keyword" placeholder="Keyword (e.g. massagem)" required class="input-industrial max-w-xs">
<button type="submit" class="btn-primary">Add Keyword</button>
</form>
%s`, buildServiceSelectOptions(services), renderKeywordTable(kws))
fmt.Fprint(buf, templates.SectionCard("Service Keywords", "Add keywords that trigger automatic service detection. Case and accent insensitive.", template.HTML(keywordForm)))
// Statuses Section
statusForm := fmt.Sprintf(`
<form hx-post="/leads/statuses" hx-target="#statusTable" hx-swap="outerHTML" class="flex flex-wrap gap-3 mb-4">
<input type="text" name="status_name" placeholder="New status name" required class="input-industrial max-w-xs">
<button type="submit" class="btn-primary">Add Status</button>
</form>
%s`, renderStatusTable(statuses))
fmt.Fprintf(buf, `<div class="mt-6">%s</div>`, templates.SectionCard("Lead Statuses", "Manage the status options available for leads.", template.HTML(statusForm)))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html> templates.WritePage(w, buf, "Keyword Mapping", "")
<html>
<head>
<meta charset="UTF-8">
<title>Keyword Mapping</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
table { border-collapse: collapse; width: 100%%; margin-bottom: 2rem; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; }
.section { margin-top: 2rem; }
form.inline { display: inline; }
</style>
</head>
<body>
<h1>Keyword Mapping</h1>
<nav>
<a href="/">Home</a> |
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
<a href="/leads/all">All Leads</a> |
<a href="/report">Monthly Report</a>
</nav>
<br>
<h2>Service Keywords</h2>
<p>Add keywords that trigger automatic service detection. Case and accent insensitive.</p>
<form hx-post="/leads/keywords" hx-target="#keywordTable" hx-swap="outerHTML">
<select name="service_name">%s</select>
<input type="text" name="keyword" placeholder="Keyword (e.g. massagem)" required>
<button type="submit">Add Keyword</button>
</form>
<br><br>
%s
<div class="section">
<h2>Lead Statuses</h2>
<p>Manage the status options available for leads.</p>
<form hx-post="/leads/statuses" hx-target="#statusTable" hx-swap="outerHTML">
<input type="text" name="status_name" placeholder="New status name" required>
<button type="submit">Add Status</button>
</form>
<br><br>
%s
</div>
</body></html>`,
pendingCount,
buildServiceSelectOptions(services),
renderKeywordTable(kws),
renderStatusTable(statuses),
)
} }
// AddServiceKeyword adds a new keyword mapping.
// POST /leads/keywords
func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) { func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -112,8 +85,6 @@ func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, renderKeywordTable(kws)) fmt.Fprint(w, renderKeywordTable(kws))
} }
// DeleteServiceKeywordHandler removes a keyword mapping.
// DELETE /leads/keywords/{id}
func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) { func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -128,8 +99,6 @@ func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request
fmt.Fprint(w, renderKeywordTable(kws)) fmt.Fprint(w, renderKeywordTable(kws))
} }
// AddLeadStatusHandler adds a new custom lead status.
// POST /leads/statuses
func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) { func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -149,8 +118,6 @@ func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, renderStatusTable(statuses)) fmt.Fprint(w, renderStatusTable(statuses))
} }
// DeleteLeadStatusHandler removes a lead status.
// DELETE /leads/statuses/{id}
func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) { func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -165,13 +132,11 @@ func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, renderStatusTable(statuses)) fmt.Fprint(w, renderStatusTable(statuses))
} }
// --- rendering helpers -------------------------------------------------------
func buildServiceSelectOptions(services []string) string { func buildServiceSelectOptions(services []string) string {
out := "" out := ""
for _, s := range services { for _, s := range services {
if s == "Não especificou" { if s == "Não especificou" {
continue // don't map keywords to the fallback continue
} }
out += fmt.Sprintf(`<option value="%s">%s</option>`, htmlEscape(s), htmlEscape(s)) out += fmt.Sprintf(`<option value="%s">%s</option>`, htmlEscape(s), htmlEscape(s))
} }
@@ -179,51 +144,47 @@ func buildServiceSelectOptions(services []string) string {
} }
func renderKeywordTable(kws []db.ServiceKeyword) string { func renderKeywordTable(kws []db.ServiceKeyword) string {
out := `<table id="keywordTable"> out := `<div id="keywordTable"><table class="data-table"><thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead><tbody>`
<thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead>
<tbody>`
if len(kws) == 0 { if len(kws) == 0 {
out += `<tr><td colspan="3">No keywords defined.</td></tr>` out += `<tr><td colspan="3" class="text-zinc-500 text-sm py-4">No keywords defined.</td></tr>`
} }
for _, kw := range kws { for _, kw := range kws {
out += fmt.Sprintf(` out += fmt.Sprintf(`
<tr> <tr>
<td>%s</td> <td class="text-zinc-200">%s</td>
<td>%s</td> <td class="font-mono text-xs text-amber-400">%s</td>
<td> <td>
<form hx-delete="/leads/keywords/%d" hx-target="#keywordTable" hx-swap="outerHTML" style="display:inline"> <form hx-delete="/leads/keywords/%d" hx-target="#keywordTable" hx-swap="outerHTML" style="display:inline">
<button type="submit">Remove</button> <button type="submit" class="btn-danger btn-sm">Remove</button>
</form> </form>
</td> </td>
</tr>`, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID) </tr>`, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID)
} }
out += `</tbody></table>` out += `</tbody></table></div>`
return out return out
} }
func renderStatusTable(statuses []db.LeadStatus) string { func renderStatusTable(statuses []db.LeadStatus) string {
out := `<table id="statusTable"> out := `<div id="statusTable"><table class="data-table"><thead><tr><th>Status</th><th>Actions</th></tr></thead><tbody>`
<thead><tr><th>Status</th><th>Actions</th></tr></thead>
<tbody>`
if len(statuses) == 0 { if len(statuses) == 0 {
out += `<tr><td colspan="2">No statuses defined.</td></tr>` out += `<tr><td colspan="2" class="text-zinc-500 text-sm py-4">No statuses defined.</td></tr>`
} }
for _, s := range statuses { for _, s := range statuses {
out += fmt.Sprintf(` out += fmt.Sprintf(`
<tr> <tr>
<td>%s</td> <td><span class="pill pill-zinc">%s</span></td>
<td> <td>
<form hx-delete="/leads/statuses/%d" hx-target="#statusTable" hx-swap="outerHTML" style="display:inline"> <form hx-delete="/leads/statuses/%d" hx-target="#statusTable" hx-swap="outerHTML" style="display:inline">
<button type="submit">Remove</button> <button type="submit" class="btn-danger btn-sm">Remove</button>
</form> </form>
</td> </td>
</tr>`, htmlEscape(s.StatusName), s.StatusID) </tr>`, htmlEscape(s.StatusName), s.StatusID)
} }
out += `</tbody></table>` out += `</tbody></table></div>`
return out return out
} }
// --- package-level shims kept for existing tests --- // --- package-level shims ---
func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) { func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r) (&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r)

View File

@@ -8,12 +8,11 @@ import (
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// LeadReviewQueue renders the review queue: leads where needs_review = 1.
// GET /leads/review
func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) { func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -40,72 +39,39 @@ func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
statuses, _ := db.ListLeadStatuses(a.DB, clientID) statuses, _ := db.ListLeadStatuses(a.DB, clientID)
services := a.serviceNames(clientID) services := a.serviceNames(clientID)
pendingCount := len(leads) pendingCount := len(leads)
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
fmt.Fprintf(w, `<!DOCTYPE html> badge := ""
<html> if pendingCount > 0 {
<head> badge = fmt.Sprintf(`<span class="ml-2 pill pill-rose">%d</span>`, pendingCount)
<title>Review Queue (%d pending)</title> }
<script src="https://unpkg.com/htmx.org@1.9.10"></script> fmt.Fprint(buf, templates.PageHeader("Review Queue", "Confirm or correct leads with unidentified service interest"))
<style> fmt.Fprintf(buf, `<div class="mb-4">%s</div>`, badge)
body { font-family: sans-serif; padding: 1rem; }
h1 { display: flex; align-items: center; gap: 0.5rem; }
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.85rem; }
table { border-collapse: collapse; width: 100%%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
form { display: inline; }
select, input[type=text] { width: 100%%; box-sizing: border-box; }
.actions button { margin-right: 4px; }
.empty { padding: 2rem; color: #888; text-align: center; }
</style>
</head>
<body>
<h1>Review Queue <span class="badge">%d</span></h1>
<p>Leads with service interest not yet identified. Confirm or correct each entry.</p>
<nav><a href="/">Home</a> | <a href="/leads/all">All Leads</a> | <a href="/leads/keywords">Keyword Mapping</a> | <a href="/report">Monthly Report</a></nav>
<br>
`, pendingCount, pendingCount)
if len(leads) == 0 { if len(leads) == 0 {
fmt.Fprintf(w, `<div class="empty">No leads pending review.</div>`) 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 { } else {
fmt.Fprintf(w, `<table> fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
<thead> fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service Interest", "Status", "Arrived", "Actions"}))
<tr>
<th>Phone</th>
<th>Name</th>
<th>Service Interest</th>
<th>Status</th>
<th>Arrived</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reviewList">`)
for _, l := range leads { for _, l := range leads {
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04") arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
fmt.Fprintf(w, ` fmt.Fprintf(buf, `<tr id="row-%d">
<tr id="row-%d"> <td class="font-mono text-xs text-zinc-300">%s</td>
<td>%s</td> <td class="font-medium text-zinc-200">%s</td>
<td>%s</td>
<td> <td>
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML"> <form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML" class="flex flex-col gap-2">
<select name="service_interest">%s</select> <select name="service_interest" class="input-industrial text-xs">%s</select>
<select name="status">%s</select> <select name="status" class="input-industrial text-xs">%s</select>
<input type="text" name="name" value="%s" placeholder="Name"> <input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
<div class="actions"> <button type="submit" class="btn-primary btn-sm self-start">Confirm</button>
<button type="submit">Confirm</button>
</div>
</form> </form>
</td> </td>
<td>%s</td> <td><span class="pill pill-amber">%s</span></td>
<td>%s</td> <td class="text-xs text-zinc-500">%s</td>
<td> <td>
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML"> <form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML" style="display:inline">
<button type="submit" onclick="return confirm('Delete this lead?')">Delete</button> <button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this lead?')">Delete</button>
</form> </form>
</td> </td>
</tr>`, </tr>`,
@@ -121,14 +87,14 @@ func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
l.LeadID, l.LeadID, l.LeadID, l.LeadID,
) )
} }
fmt.Fprintf(w, `</tbody></table>`) fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
} }
fmt.Fprintf(w, `</body></html>`) w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Review Queue", "review")
} }
// ConfirmLeadReview handles the form submission from the review queue.
// PUT /leads/{id}/review
func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) { func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -161,11 +127,9 @@ func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<tr id="row-%d" style="display:none"></tr>`, leadID) fmt.Fprintf(w, `<tr id="row-%d" class="htmx-swapping" style="display:none"></tr>`, leadID)
} }
// LeadAllList renders all leads (not just review queue).
// GET /leads/all
func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) { func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -192,7 +156,6 @@ func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
http.Error(w, svcErr.Error(), http.StatusInternalServerError) http.Error(w, svcErr.Error(), http.StatusInternalServerError)
return return
} }
// convert domain.Leaf to db.Lead
leads = make([]db.Lead, len(domainLeads)) leads = make([]db.Lead, len(domainLeads))
for i, dl := range domainLeads { for i, dl := range domainLeads {
leads[i] = db.Lead{ leads[i] = db.Lead{
@@ -225,98 +188,83 @@ func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
statuses, _ := db.ListLeadStatuses(a.DB, clientID) statuses, _ := db.ListLeadStatuses(a.DB, clientID)
services := a.serviceNames(clientID) services := a.serviceNames(clientID)
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
fmt.Fprintf(w, `<!DOCTYPE html> fmt.Fprint(buf, templates.PageHeader("All Leads", "Complete lead database with filters and inline editing"))
<html>
<head>
<title>All Leads</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
table { border-collapse: collapse; width: 100%%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; }
.needs-review { background: #fff3cd; }
select, input[type=text] { width: 100%%; box-sizing: border-box; }
</style>
</head>
<body>
<h1>All Leads</h1>
<nav>
<a href="/">Home</a> |
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
<a href="/leads/keywords">Keyword Mapping</a> |
<a href="/report">Monthly Report</a>
</nav>
<br>
<table>
<thead>
<tr>
<th>Phone</th><th>Name</th><th>Service</th><th>Status</th>
<th>Payment</th><th>Arrived</th><th>Last Contact</th><th>Actions</th>
</tr>
</thead>
<tbody id="leadsList">
`, pendingCount)
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 { for _, l := range leads {
arrived := time.Unix(l.CreatedAt, 0).Format("02/01/2006 15:04") arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
lastContact := time.Unix(l.LastContactAt, 0).Format("02/01/2006 15:04") lastContact := time.Unix(l.LastContactAt, 0).Format("02/01 15:04")
rowClass := "" rowClass := ""
if l.NeedsReview { if l.NeedsReview {
rowClass = `class="needs-review"` rowClass = `class="bg-amber-400/[0.03]"`
} }
fmt.Fprintf(w, ` statusPill := statusPillHTML(l.Status)
<tr %s id="lead-%d"> fmt.Fprintf(buf, `<tr %s id="lead-%d">
<td>%s</td> <td class="font-mono text-xs text-zinc-300">%s</td>
<td>%s</td> <td class="font-medium text-zinc-200">%s %s</td>
<td>%s</td> <td class="text-zinc-400 text-xs">%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%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> <td>
<button onclick="document.getElementById('edit-%d').style.display='table-row'">Edit</button> <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"> <form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
<button onclick="return confirm('Delete?')">Delete</button> <button class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
</form>
</td>
</tr>
<tr id="edit-%d" style="display:none">
<td colspan="8">
<form hx-put="/leads/%d/review" hx-target="#lead-%d" hx-swap="outerHTML">
<input type="text" name="name" value="%s" placeholder="Name">
<select name="service_interest">%s</select>
<select name="status">%s</select>
<button type="submit">Save</button>
<button type="button" onclick="document.getElementById('edit-%d').style.display='none'">Cancel</button>
</form> </form>
</div>
</td> </td>
</tr>`, </tr>`,
rowClass, l.LeadID, rowClass, l.LeadID,
htmlEscape(l.PhoneNormalized), htmlEscape(l.PhoneNormalized),
htmlEscape(l.Name), 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), htmlEscape(l.ServiceInterest),
htmlEscape(l.Status), statusPill,
htmlEscape(l.PaymentStatus), htmlEscape(l.PaymentStatus),
arrived, arrived,
lastContact, lastContact,
l.LeadID, l.LeadID,
l.LeadID, l.LeadID, l.LeadID, l.LeadID,
l.LeadID,
l.LeadID, l.LeadID,
htmlEscape(l.Name),
buildServiceOptions(services, l.ServiceInterest),
buildStatusOptions(statuses, l.Status),
l.LeadID,
) )
} }
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
fmt.Fprintf(w, `</tbody></table></body></html>`) // 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")
} }
// DeleteLeadNew handles DELETE /leads/{id}
func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) { func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -330,8 +278,6 @@ func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
} }
// serviceNames returns the list of service names for the review/all-leads dropdowns. // serviceNames returns the list of service names for the review/all-leads dropdowns.
// It reads from the DB-backed services table first, then falls back to the
// hardcoded defaults so the dropdown is never empty on a fresh install.
func (a *App) serviceNames(clientID int64) []string { func (a *App) serviceNames(clientID int64) []string {
rows, err := a.DB.Query( rows, err := a.DB.Query(
"SELECT DISTINCT name FROM services WHERE client_id = ? ORDER BY name", "SELECT DISTINCT name FROM services WHERE client_id = ? ORDER BY name",
@@ -347,7 +293,6 @@ func (a *App) serviceNames(clientID int64) []string {
} }
} }
if len(names) > 0 { if len(names) > 0 {
// Ensure "Não especificou" is always first.
hasDefault := false hasDefault := false
for _, n := range names { for _, n := range names {
if n == "Não especificou" { if n == "Não especificou" {
@@ -361,7 +306,6 @@ func (a *App) serviceNames(clientID int64) []string {
return names return names
} }
} }
// Fallback: hardcoded defaults for a fresh install with no services yet.
return []string{ return []string{
"Não especificou", "Não especificou",
"Head Spa", "Head Spa",
@@ -373,8 +317,6 @@ func (a *App) serviceNames(clientID int64) []string {
} }
} }
// --- rendering helpers -------------------------------------------------------
func buildServiceOptions(services []string, selected string) string { func buildServiceOptions(services []string, selected string) string {
out := "" out := ""
for _, s := range services { for _, s := range services {

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -10,6 +11,7 @@ import (
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"go-crm/internal/whatsapp" "go-crm/internal/whatsapp"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -32,8 +34,8 @@ func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
args := []interface{}{accountID} args := []interface{}{accountID}
if search != "" { if search != "" {
query += " AND (name LIKE ? OR phone LIKE ?)" query += " AND (cu.name LIKE ? OR cu.phone LIKE ?)"
searchPat := "%" + search + "%" searchPat := "%%" + search + "%%"
args = append(args, searchPat, searchPat) args = append(args, searchPat, searchPat)
} }
@@ -56,72 +58,50 @@ func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
customers = append(customers, c) customers = append(customers, c)
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html> fmt.Fprint(buf, templates.PageHeader("Leads", "Search and manage customer leads"))
<html>
<head> // Search
<title>Leads</title> fmt.Fprintf(buf, `
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <div class="card-industrial p-4 mb-6 animate-slide-up">
<style> <form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML" class="flex gap-3">
body { font-family: sans-serif; padding: 1rem; } <input type="text" name="search" placeholder="Search by name or phone..." value="%s" class="input-industrial max-w-md">
table { border-collapse: collapse; width: 100%; } <button type="submit" class="btn-primary">Search</button>
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } </form>
th { background: #f5f5f5; } </div>`, htmlEscape(search))
.search-box { margin-bottom: 1rem; }
.edit-row { display: none; } if len(customers) == 0 {
</style> 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."))
</head> } else {
<body> fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up stagger-1"><div class="overflow-x-auto">`)
<h1>Leads</h1> fmt.Fprint(buf, templates.TableStart([]string{"Name", "Phone", "Birth Date", "Instagram", "WhatsApp", "Actions"}))
<div class="search-box"> for _, c := range customers {
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML"> waStatus := `<span class="text-zinc-600 text-xs">—</span>`
<input type="text" name="search" placeholder="Search by name or phone" value="` + search + `"> if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
<button type="submit">Search</button> 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> </form>
</div> </div>
<table> </td>
<thead> </tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), waStatus, c.CustomerID, c.CustomerID)
<tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>WhatsApp</th><th>Actions</th></tr>
</thead>
<tbody id="leadList">
`))
for _, c := range customers {
waStatus := "Not connected"
waStyle := "color: #999;"
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
waStatus = c.WhatsAppNumber
waStyle = "color: #28a745; font-weight: 600;"
} }
w.Write([]byte(`<tr> fmt.Fprint(buf, templates.TableEnd())
<td>` + c.Name + `</td> fmt.Fprintf(buf, `</div></div>`)
<td>` + c.Phone + `</td>
<td>` + c.BirthDate + `</td>
<td>` + c.Instagram + `</td>
<td style="` + waStyle + `">` + waStatus + `</td>
<td>
<button type="button" onclick="document.getElementById('editLead` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='table-row'">Edit</button>
<form method="DELETE" style="display:inline" hx-delete="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr">
<button type="submit">Delete</button>
</form>
</td>
</tr>
<tr id="editLead` + strconv.FormatInt(c.CustomerID, 10) + `" class="edit-row">
<td colspan="6">
<form hx-put="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="name" value="` + c.Name + `">
<input type="tel" name="phone" value="` + c.Phone + `">
<input type="date" name="birth_date" value="` + c.BirthDate + `">
<input type="text" name="instagram" value="` + c.Instagram + `">
<button type="submit">Save</button>
</form>
</td>
</tr>`))
} }
w.Write([]byte(`</tbody></table> w.Header().Set("Content-Type", "text/html; charset=utf-8")
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p> templates.WritePage(w, buf, "Leads", "leads")
</body></html>`))
} }
func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) {
@@ -163,7 +143,7 @@ func (a *App) DeleteLead(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("OK")) w.Write([]byte(""))
} }
func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) { func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
@@ -179,58 +159,47 @@ func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html> fmt.Fprint(buf, templates.PageHeader("Connect WhatsApp", fmt.Sprintf("Scan QR code to link %s", htmlEscape(client.Name))))
<html>
<head> fmt.Fprintf(buf, `
<title>Connect WhatsApp</title> <div class="max-w-lg mx-auto animate-slide-up">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <div class="card-industrial p-8 text-center">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> <div id="qrcode" class="flex justify-center mb-6 min-h-[256px] items-center">
<style> <div class="spinner"></div>
body { font-family: sans-serif; padding: 2rem; text-align: center; } </div>
#qrcode { margin: 2rem auto; display: flex; justify-content: center; } <p id="status" class="text-sm text-zinc-400 font-mono">Loading QR code...</p>
#status { padding: 1rem; } </div>
</style> </div>
</head> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<body> <script>
<h1>Connect WhatsApp</h1> var lastQR = '';
<p>Scan the QR code below with your WhatsApp app to connect</p> function pollQR() {
<div id="qrcode"></div> fetch('/leads/qr?client_id=%d')
<p id="status">Loading...</p>
<script>
var lastQR = '';
function pollQR() {
fetch('/leads/qr?client_id=` + strconv.FormatInt(client.ClientID, 10) + `')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.qr) { if (data.qr) {
if (data.qr !== lastQR) { if (data.qr !== lastQR) {
lastQR = data.qr; lastQR = data.qr;
document.getElementById('qrcode').innerHTML = ''; document.getElementById('qrcode').innerHTML = '';
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), { text: data.qr, width: 256, height: 256 });
text: data.qr,
width: 256,
height: 256
});
} }
document.getElementById('status').textContent = 'Scan with WhatsApp'; document.getElementById('status').textContent = 'Scan with WhatsApp';
setTimeout(pollQR, 5000); setTimeout(pollQR, 5000);
} else if (data.status === 'ready') { } else if (data.status === 'ready') {
document.getElementById('status').textContent = 'Connected! Verifying phone...'; document.getElementById('status').textContent = 'Connected! Verifying phone...';
document.getElementById('qrcode').innerHTML = '&#10003;'; 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(() => { setTimeout(() => {
fetch('/leads/verify/` + strconv.FormatInt(client.ClientID, 10) + `') fetch('/leads/verify/%d')
.then(r => r.json()) .then(r => r.json())
.then(v => { .then(v => {
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown'); var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
if (v.match === 'yes') { if (v.match === 'yes') {
document.getElementById('status').textContent = msg + ' — matches ' + (v.client_phone || '') + ' ✓'; document.getElementById('status').innerHTML = msg + ' matches client phone &mdash; <span class="text-emerald-400">verified</span>';
document.getElementById('status').style.color = '#28a745';
} else if (v.match === 'no') { } else if (v.match === 'no') {
document.getElementById('status').textContent = msg + ' — does NOT match client phone ' + (v.client_phone || '') + ' ⚠'; document.getElementById('status').innerHTML = msg + ' does NOT match client phone &mdash; <span class="text-rose-400">mismatch</span>';
document.getElementById('status').style.color = '#dc3545';
} else { } else {
document.getElementById('status').textContent = msg + ' (client phone unknown — verify manually)'; document.getElementById('status').textContent = msg + ' (client phone unknown)';
} }
}) })
.catch(() => { .catch(() => {
@@ -238,7 +207,7 @@ func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
}); });
}, 1500); }, 1500);
} else if (data.status === 'error') { } else if (data.status === 'error') {
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...'; document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' retrying...';
setTimeout(pollQR, 8000); setTimeout(pollQR, 8000);
} else { } else {
document.getElementById('status').textContent = 'Status: ' + data.status; document.getElementById('status').textContent = 'Status: ' + data.status;
@@ -246,14 +215,15 @@ func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
} }
}) })
.catch(err => { .catch(err => {
document.getElementById('status').textContent = 'Connection error — retrying...'; document.getElementById('status').textContent = 'Connection error retrying...';
setTimeout(pollQR, 5000); setTimeout(pollQR, 5000);
}); });
} }
pollQR(); pollQR();
</script> </script>`, client.ClientID, client.ClientID)
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
</body></html>`)) w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Connect WhatsApp", "leads")
} }
func jsonEscape(s string) string { func jsonEscape(s string) string {
@@ -349,8 +319,8 @@ func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
match := "unknown" match := "unknown"
if client.WhatsAppNumber != "" && client.Phone != "" { if client.WhatsAppNumber != "" && client.Phone != "" {
cleanWA := strings.TrimPrefix(client.WhatsAppNumber, "+") cleanWA := cleanPhone(client.WhatsAppNumber)
cleanClient := strings.TrimPrefix(client.Phone, "+") cleanClient := cleanPhone(client.Phone)
if cleanWA == cleanClient { if cleanWA == cleanClient {
match = "yes" match = "yes"
} else { } else {
@@ -362,6 +332,12 @@ func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok","wa_phone":"` + jsonEscape(client.WhatsAppNumber) + `","client_phone":"` + jsonEscape(client.Phone) + `","match":"` + match + `","client_name":"` + jsonEscape(client.Name) + `"}`)) 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 --- // --- package-level shims kept for existing tests ---
func ListLeads(w http.ResponseWriter, r *http.Request) { func ListLeads(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -45,20 +47,46 @@ func (a *App) ListPayments(w http.ResponseWriter, r *http.Request) {
clientNames := make(map[int64]string) clientNames := make(map[int64]string)
customerNames := make(map[int64]string) customerNames := make(map[int64]string)
for _, c := range clients { for _, c := range clients {
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` clientOptions += fmt.Sprintf(`<option value="%d">%s</option>`, c.ClientID, htmlEscape(c.Name))
clientNames[c.ClientID] = c.Name clientNames[c.ClientID] = c.Name
} }
for _, cu := range customers { for _, cu := range customers {
customerOptions += `<option value="` + strconv.FormatInt(cu.CustomerID, 10) + `">` + cu.Name + `</option>` customerOptions += fmt.Sprintf(`<option value="%d">%s</option>`, cu.CustomerID, htmlEscape(cu.Name))
customerNames[cu.CustomerID] = cu.Name customerNames[cu.CustomerID] = cu.Name
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script><style>.edit-row{display:none}</style></head><body><h1>Payments</h1><button class="btn" onclick="document.getElementById('paymentForm').style.display='block'">Add Payment</button><div id="paymentForm" style="display:none; margin-top:1rem;"><form hx-post="/payments" hx-target="#paymentList"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><select name="customer_id" required><option value="">Select Customer</option>` + customerOptions + `</select><input type="checkbox" name="has_paid"><label>Paid</label><input type="number" name="amount" placeholder="Amount" step="0.01"><input type="date" name="payment_date"><select name="payment_method"><option value="">Select Payment Method</option><option value="PIX">PIX</option><option value="Dinheiro">Dinheiro</option><option value="Débito">Débito</option><option value="Crédito à Vista">Crédito à Vista</option><option value="Parcelado">Parcelado</option><option value="Boleto">Boleto</option><option value="Transferência Bancária">Transferência Bancária</option></select><button type="submit">Add</button></form></div><table><thead><tr><th>Client</th><th>Customer</th><th>Amount</th><th>Paid</th><th>Date</th><th>Method</th><th>Actions</th></tr></thead><tbody id="paymentList">`)) actions := `<button type="button" onclick="document.getElementById('paymentForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Payment</button>`
fmt.Fprint(buf, templates.PageHeader("Payments", "Track revenue and transactions", actions))
fmt.Fprintf(buf, `
<div id="paymentForm" class="hidden mb-6 animate-slide-up">
<div class="card-industrial p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-4">New Payment</h3>
<form hx-post="/payments" hx-target="#paymentList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<select name="client_id" required class="input-industrial"><option value="">Select Client</option>%s</select>
<select name="customer_id" required class="input-industrial"><option value="">Select Customer</option>%s</select>
<input type="number" name="amount" placeholder="Amount" step="0.01" class="input-industrial">
<input type="date" name="payment_date" class="input-industrial">
<select name="payment_method" class="input-industrial"><option value="">Method</option><option value="PIX">PIX</option><option value="Dinheiro">Dinheiro</option><option value="Débito">Débito</option><option value="Crédito à Vista">Crédito à Vista</option><option value="Parcelado">Parcelado</option><option value="Boleto">Boleto</option><option value="Transferência Bancária">Transferência Bancária</option></select>
<label class="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer select-none">
<input type="checkbox" name="has_paid" class="w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-amber-400 focus:ring-amber-400/20">
<span>Paid</span>
</label>
<div class="flex items-end"><button type="submit" class="btn-primary">Save</button></div>
</form>
</div>
</div>`, clientOptions, customerOptions)
if len(payments) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"/>`, "No payments recorded yet."))
} 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{"Client", "Customer", "Amount", "Paid", "Date", "Method", "Actions"}))
for _, p := range payments { for _, p := range payments {
paid := "No" paidPill := `<span class="pill pill-rose">unpaid</span>`
if p.HasPaid { if p.HasPaid {
paid = "Yes" paidPill = `<span class="pill pill-emerald">paid</span>`
} }
clientName := clientNames[p.ClientID] clientName := clientNames[p.ClientID]
if clientName == "" { if clientName == "" {
@@ -68,9 +96,56 @@ func (a *App) ListPayments(w http.ResponseWriter, r *http.Request) {
if customerName == "" { if customerName == "" {
customerName = strconv.FormatInt(p.CustomerID, 10) customerName = strconv.FormatInt(p.CustomerID, 10)
} }
w.Write([]byte(`<tr><td>` + clientName + `</td><td>` + customerName + `</td><td>` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `</td><td>` + paid + `</td><td>` + p.PaymentDate + `</td><td>` + p.PaymentMethod + `</td><td><a href="/payments/` + strconv.FormatInt(p.PaymentID, 10) + `">View</a><button type="button" onclick="document.getElementById('editPay` + strconv.FormatInt(p.PaymentID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/payments/` + strconv.FormatInt(p.PaymentID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editPay` + strconv.FormatInt(p.PaymentID, 10) + `" style="display:none"><td colspan="7"><form hx-put="/payments/` + strconv.FormatInt(p.PaymentID, 10) + `" hx-target="#paymentList" hx-swap="innerHTML"><select name="client_id"><option value="` + strconv.FormatInt(p.ClientID, 10) + `">` + clientName + `</option>` + clientOptions + `</select><select name="customer_id"><option value="` + strconv.FormatInt(p.CustomerID, 10) + `">` + customerName + `</option>` + customerOptions + `</select><input type="checkbox" name="has_paid"><input type="number" name="amount" value="` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `"><input type="date" name="payment_date" value="` + p.PaymentDate + `"><select name="payment_method"><option value="` + p.PaymentMethod + `">` + p.PaymentMethod + `</option><option value="PIX">PIX</option><option value="Dinheiro">Dinheiro</option><option value="Débito">Débito</option><option value="Crédito à Vista">Crédito à Vista</option><option value="Parcelado">Parcelado</option><option value="Boleto">Boleto</option><option value="Transferência Bancária">Transferência Bancária</option></select><button type="submit">Save</button></form></td></tr>`)) fmt.Fprintf(buf, `<tr>
<td class="text-zinc-300 text-sm">%s</td>
<td class="text-zinc-300 text-sm">%s</td>
<td class="font-mono text-sm text-emerald-400">R$ %.2f</td>
<td>%s</td>
<td class="font-mono text-xs text-zinc-400">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td>
<div class="flex items-center gap-2">
<a href="/payments/%d" class="btn-ghost btn-sm">View</a>
<button type="button" onclick="document.getElementById('editPay%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/payments/%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(clientName), htmlEscape(customerName), p.Amount, paidPill, htmlEscape(p.PaymentDate), htmlEscape(p.PaymentMethod), p.PaymentID, p.PaymentID, p.PaymentID)
} }
w.Write([]byte(`</tbody></table></body></html>`)) fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
for _, p := range payments {
clientName := clientNames[p.ClientID]
customerName := customerNames[p.CustomerID]
fmt.Fprintf(buf, `
<div id="editPay%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 Payment #%d</h3>
<form hx-put="/payments/%d" hx-target="#paymentList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<select name="client_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<select name="customer_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<input type="number" name="amount" value="%.2f" step="0.01" class="input-industrial text-xs">
<input type="date" name="payment_date" value="%s" class="input-industrial text-xs">
<select name="payment_method" class="input-industrial text-xs"><option value="%s">%s</option><option value="PIX">PIX</option><option value="Dinheiro">Dinheiro</option><option value="Débito">Débito</option><option value="Crédito à Vista">Crédito à Vista</option><option value="Parcelado">Parcelado</option><option value="Boleto">Boleto</option><option value="Transferência Bancária">Transferência Bancária</option></select>
<label class="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none">
<input type="checkbox" name="has_paid" class="w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-amber-400">
<span>Paid</span>
</label>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" onclick="document.getElementById('editPay%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, p.PaymentID, p.PaymentID, p.PaymentID, p.ClientID, htmlEscape(clientName), clientOptions, p.CustomerID, htmlEscape(customerName), customerOptions, p.Amount, htmlEscape(p.PaymentDate), p.PaymentMethod, p.PaymentMethod, p.PaymentID)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Payments", "payments")
} }
func (a *App) CreatePayment(w http.ResponseWriter, r *http.Request) { func (a *App) CreatePayment(w http.ResponseWriter, r *http.Request) {
@@ -111,7 +186,7 @@ func (a *App) ViewPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
var p db.Payment var p db.Payment
var hasPaid int var hasPaid int
err := a.DB.QueryRow("SELECT payment_id, client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at FROM payments WHERE payment_id = ?", id).Scan(&p.PaymentID, &p.ClientID, &p.CustomerID, &p.ScheduleID, &hasPaid, &p.Amount, &p.PaymentDate, &p.PaymentMethod, &p.CreatedAt) err := a.DB.QueryRow("SELECT payment_id, client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at FROM payments WHERE payment_id = ?", id).Scan(&p.PaymentID, &p.ClientID, &p.CustomerID, &p.ScheduleID, &hasPaid, &p.Amount, &p.PaymentDate, &p.PaymentMethod, &p.CreatedAt)
@@ -121,8 +196,38 @@ func (a *App) ViewPayment(w http.ResponseWriter, r *http.Request) {
} }
p.HasPaid = hasPaid == 1 p.HasPaid = hasPaid == 1
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Payment", "Transaction details"))
paidPill := `<span class="pill pill-rose">unpaid</span>`
if p.HasPaid {
paidPill = `<span class="pill pill-emerald">paid</span>`
}
fmt.Fprintf(buf, `
<div class="max-w-lg animate-slide-up">
<div class="card-industrial p-6">
<div class="flex items-center gap-2 mb-6">%s</div>
<div class="space-y-3">
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Amount</span>
<span class="text-lg font-mono font-semibold text-emerald-400">R$ %.2f</span>
</div>
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Method</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-3">
<span class="text-xs font-mono uppercase text-zinc-500">Date</span>
<span class="text-sm font-mono text-zinc-300">%s</span>
</div>
</div>
<div class="mt-6">
<a href="/payments" class="btn-ghost btn-sm">Back</a>
</div>
</div>
</div>`, paidPill, p.Amount, htmlEscape(p.PaymentMethod), htmlEscape(p.PaymentDate))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><body><h1>Payment</h1><p>Amount: ` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `</p><p>Paid: ` + strconv.FormatBool(p.HasPaid) + `</p><p>Method: ` + p.PaymentMethod + `</p><a href="/payments">Back</a></body></html>`)) templates.WritePage(w, buf, "Payment", "payments")
} }
func (a *App) UpdatePayment(w http.ResponseWriter, r *http.Request) { func (a *App) UpdatePayment(w http.ResponseWriter, r *http.Request) {
@@ -151,11 +256,11 @@ func (a *App) DeletePayment(w http.ResponseWriter, r *http.Request) {
return return
} }
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
a.DB.Exec("DELETE FROM payments WHERE payment_id = ?", id) a.DB.Exec("DELETE FROM payments WHERE payment_id = ?", id)
} }
// --- package-level shims kept for existing tests --- // --- package-level shims ---
func ListPayments(w http.ResponseWriter, r *http.Request) { func ListPayments(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListPayments(w, r) (&App{DB: DB, WAConnector: WAConnector}).ListPayments(w, r)

View File

@@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -45,16 +47,37 @@ func (a *App) ListQuestions(w http.ResponseWriter, r *http.Request) {
clientNames := make(map[int64]string) clientNames := make(map[int64]string)
customerNames := make(map[int64]string) customerNames := make(map[int64]string)
for _, c := range clients { for _, c := range clients {
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` clientOptions += fmt.Sprintf(`<option value="%d">%s</option>`, c.ClientID, htmlEscape(c.Name))
clientNames[c.ClientID] = c.Name clientNames[c.ClientID] = c.Name
} }
for _, cu := range customers { for _, cu := range customers {
customerOptions += `<option value="` + strconv.FormatInt(cu.CustomerID, 10) + `">` + cu.Name + `</option>` customerOptions += fmt.Sprintf(`<option value="%d">%s</option>`, cu.CustomerID, htmlEscape(cu.Name))
customerNames[cu.CustomerID] = cu.Name customerNames[cu.CustomerID] = cu.Name
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script><style>.edit-row{display:none}</style></head><body><h1>Questions</h1><button class="btn" onclick="document.getElementById('questionForm').style.display='block'">Add Question</button><div id="questionForm" style="display:none; margin-top:1rem;"><form hx-post="/questions" hx-target="#questionList"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><select name="customer_id" required><option value="">Select Customer</option>` + customerOptions + `</select><textarea name="question" placeholder="Question" required></textarea><select name="status"><option value="pending">Pending</option><option value="answered">Answered</option></select><button type="submit">Add</button></form></div><table><thead><tr><th>Client</th><th>Customer</th><th>Question</th><th>Status</th><th>Actions</th></tr></thead><tbody id="questionList">`)) actions := `<button type="button" onclick="document.getElementById('questionForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Question</button>`
fmt.Fprint(buf, templates.PageHeader("Questions", "Customer inquiries and Q&A", actions))
fmt.Fprintf(buf, `
<div id="questionForm" class="hidden mb-6 animate-slide-up">
<div class="card-industrial p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-4">New Question</h3>
<form hx-post="/questions" hx-target="#questionList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<select name="client_id" required class="input-industrial"><option value="">Select Client</option>%s</select>
<select name="customer_id" required class="input-industrial"><option value="">Select Customer</option>%s</select>
<textarea name="question" placeholder="Question text" required class="input-industrial sm:col-span-2" rows="2"></textarea>
<select name="status" class="input-industrial"><option value="pending">Pending</option><option value="answered">Answered</option></select>
<div class="flex items-end"><button type="submit" class="btn-primary">Save</button></div>
</form>
</div>
</div>`, clientOptions, customerOptions)
if len(questions) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>`, "No questions recorded yet."))
} 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{"Client", "Customer", "Question", "Status", "Actions"}))
for _, q := range questions { for _, q := range questions {
clientName := clientNames[q.ClientID] clientName := clientNames[q.ClientID]
if clientName == "" { if clientName == "" {
@@ -64,9 +87,53 @@ func (a *App) ListQuestions(w http.ResponseWriter, r *http.Request) {
if customerName == "" { if customerName == "" {
customerName = strconv.FormatInt(q.CustomerID, 10) customerName = strconv.FormatInt(q.CustomerID, 10)
} }
w.Write([]byte(`<tr><td>` + clientName + `</td><td>` + customerName + `</td><td>` + q.Question + `</td><td>` + q.Status + `</td><td><a href="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `">View</a><button type="button" onclick="document.getElementById('editQ` + strconv.FormatInt(q.QuestionID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editQ` + strconv.FormatInt(q.QuestionID, 10) + `" style="display:none"><td colspan="5"><form hx-put="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `" hx-target="#questionList" hx-swap="innerHTML"><select name="client_id"><option value="` + strconv.FormatInt(q.ClientID, 10) + `">` + clientName + `</option>` + clientOptions + `</select><select name="customer_id"><option value="` + strconv.FormatInt(q.CustomerID, 10) + `">` + customerName + `</option>` + customerOptions + `</select><textarea name="question">` + q.Question + `</textarea><select name="status"><option value="` + q.Status + `">` + q.Status + `</option><option value="pending">Pending</option><option value="answered">Answered</option></select><button type="submit">Save</button></form></td></tr>`)) statusPill := `<span class="pill pill-amber">pending</span>`
if q.Status == "answered" {
statusPill = `<span class="pill pill-emerald">answered</span>`
} }
w.Write([]byte(`</tbody></table></body></html>`)) fmt.Fprintf(buf, `<tr>
<td class="text-zinc-300 text-sm">%s</td>
<td class="text-zinc-300 text-sm">%s</td>
<td class="text-zinc-400 text-xs max-w-xs truncate">%s</td>
<td>%s</td>
<td>
<div class="flex items-center gap-2">
<a href="/questions/%d" class="btn-ghost btn-sm">View</a>
<button type="button" onclick="document.getElementById('editQ%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/questions/%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(clientName), htmlEscape(customerName), htmlEscape(q.Question), statusPill, q.QuestionID, q.QuestionID, q.QuestionID)
}
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
for _, q := range questions {
clientName := clientNames[q.ClientID]
customerName := customerNames[q.CustomerID]
fmt.Fprintf(buf, `
<div id="editQ%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 Question #%d</h3>
<form hx-put="/questions/%d" hx-target="#questionList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<select name="client_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<select name="customer_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<textarea name="question" class="input-industrial text-xs sm:col-span-2" rows="2">%s</textarea>
<select name="status" class="input-industrial text-xs"><option value="%s">%s</option><option value="pending">Pending</option><option value="answered">Answered</option></select>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" onclick="document.getElementById('editQ%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, q.QuestionID, q.QuestionID, q.QuestionID, q.ClientID, htmlEscape(clientName), clientOptions, q.CustomerID, htmlEscape(customerName), customerOptions, htmlEscape(q.Question), q.Status, q.Status, q.QuestionID)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Questions", "questions")
} }
func (a *App) CreateQuestion(w http.ResponseWriter, r *http.Request) { func (a *App) CreateQuestion(w http.ResponseWriter, r *http.Request) {
@@ -109,8 +176,29 @@ func (a *App) ViewQuestion(w http.ResponseWriter, r *http.Request) {
return return
} }
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Question", "Inquiry details"))
statusPill := `<span class="pill pill-amber">pending</span>`
if q.Status == "answered" {
statusPill = `<span class="pill pill-emerald">answered</span>`
}
fmt.Fprintf(buf, `
<div class="max-w-lg animate-slide-up">
<div class="card-industrial p-6">
<div class="flex items-center gap-2 mb-4">%s</div>
<div class="text-sm text-zinc-200 leading-relaxed mb-6">%s</div>
<div class="space-y-2 text-xs text-zinc-500 font-mono">
<div>Client ID: %d</div>
<div>Customer ID: %d</div>
</div>
<div class="mt-6">
<a href="/questions" class="btn-ghost btn-sm">Back</a>
</div>
</div>
</div>`, statusPill, htmlEscape(q.Question), q.ClientID, q.CustomerID)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><body><h1>Question</h1><p>Client ID: ` + strconv.FormatInt(q.ClientID, 10) + `</p><p>Customer ID: ` + strconv.FormatInt(q.CustomerID, 10) + `</p><p>Question: ` + q.Question + `</p><p>Status: ` + q.Status + `</p><a href="/questions">Back</a></body></html>`)) templates.WritePage(w, buf, "Question", "questions")
} }
func (a *App) UpdateQuestion(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateQuestion(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +229,8 @@ func (a *App) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
a.DB.Exec("DELETE FROM questions WHERE question_id = ?", id) a.DB.Exec("DELETE FROM questions WHERE question_id = ?", id)
} }
// --- Answers (in same file as original) ---
func (a *App) ListAnswers(w http.ResponseWriter, r *http.Request) { func (a *App) ListAnswers(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -169,39 +259,31 @@ func (a *App) ListAnswers(w http.ResponseWriter, r *http.Request) {
var questionOptions string var questionOptions string
questionText := make(map[int64]string) questionText := make(map[int64]string)
for _, q := range questions { for _, q := range questions {
questionOptions += `<option value="` + strconv.FormatInt(q.QuestionID, 10) + `">` + q.Question + `</option>` questionOptions += fmt.Sprintf(`<option value="%d">%s</option>`, q.QuestionID, htmlEscape(q.Question))
questionText[q.QuestionID] = q.Question questionText[q.QuestionID] = q.Question
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html> actions := `<button type="button" onclick="document.getElementById('answerForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Answer</button>`
<html> fmt.Fprint(buf, templates.PageHeader("Answers", "Responses to customer questions", actions))
<head>
<script src="https://unpkg.com/htmx.org@1.9.10"></script> fmt.Fprintf(buf, `
<style> <div id="answerForm" class="hidden mb-6 animate-slide-up">
.edit-form { display:none; margin-top:0.5rem; padding:0.5rem; border:1px solid #ccc; } <div class="card-industrial p-5">
.edit-row { display:none; } <h3 class="text-sm font-semibold text-zinc-200 mb-4">New Answer</h3>
</style> <form hx-post="/answers" hx-target="#answerList" hx-swap="innerHTML" class="grid grid-cols-1 gap-4">
</head> <select name="question_id" required class="input-industrial max-w-md"><option value="">Select Question</option>%s</select>
<body> <textarea name="answer" placeholder="Answer text" required class="input-industrial" rows="3"></textarea>
<h1>Answers</h1> <div><button type="submit" class="btn-primary">Save</button></div>
<button class="btn" onclick="document.getElementById('answerForm').style.display='block'">Add Answer</button>
<div id="answerForm" style="display:none; margin-top:1rem;">
<form hx-post="/answers" hx-target="#answerList" hx-swap="innerHTML">
<select name="question_id" required>
<option value="">Select Question</option>
` + questionOptions + `
</select>
<textarea name="answer" placeholder="Answer" required></textarea>
<button type="submit">Add Answer</button>
</form> </form>
</div> </div>
<table> </div>`, questionOptions)
<thead>
<tr><th>Client</th><th>Customer</th><th>Question</th><th>Answer</th><th>Status</th><th>Actions</th></tr> if len(answers) == 0 {
</thead> fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>`, "No answers recorded yet."))
<tbody id="answerList"> } 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{"Client", "Customer", "Question", "Answer", "Actions"}))
for _, a2 := range answers { for _, a2 := range answers {
qText := a2.QuestionText qText := a2.QuestionText
if qText == "" { if qText == "" {
@@ -218,39 +300,52 @@ func (a *App) ListAnswers(w http.ResponseWriter, r *http.Request) {
if customerName == "" { if customerName == "" {
customerName = "Unknown" customerName = "Unknown"
} }
w.Write([]byte(` fmt.Fprintf(buf, `<tr>
<tr> <td class="text-zinc-300 text-sm">%s</td>
<td>` + clientName + `</td> <td class="text-zinc-300 text-sm">%s</td>
<td>` + customerName + `</td> <td class="text-zinc-400 text-xs max-w-[200px] truncate">%s</td>
<td>` + qText + `</td> <td class="text-zinc-400 text-xs max-w-[300px] truncate">%s</td>
<td>` + a2.Answer + `</td>
<td>` + a2.Status + `</td>
<td> <td>
<a href="/answers/` + strconv.FormatInt(a2.AnswerID, 10) + `">View</a> <div class="flex items-center gap-2">
<button type="button" onclick="document.getElementById('editA` + strconv.FormatInt(a2.AnswerID, 10) + `').style.display='table-row'">Edit</button> <a href="/answers/%d" class="btn-ghost btn-sm">View</a>
<form method="DELETE" style="display:inline" hx-delete="/answers/` + strconv.FormatInt(a2.AnswerID, 10) + `" hx-target="closest tr"> <button type="button" onclick="document.getElementById('editA%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<button type="submit">Delete</button> <form hx-delete="/answers/%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> </form>
</div>
</td> </td>
</tr> </tr>`, htmlEscape(clientName), htmlEscape(customerName), htmlEscape(qText), htmlEscape(a2.Answer), a2.AnswerID, a2.AnswerID, a2.AnswerID)
<tr id="editA` + strconv.FormatInt(a2.AnswerID, 10) + `" class="edit-row" style="display:none">
<td colspan="6">
<form hx-put="/answers/` + strconv.FormatInt(a2.AnswerID, 10) + `" hx-target="#answerList" hx-swap="innerHTML">
<select name="question_id">
<option value="` + strconv.FormatInt(a2.QuestionID, 10) + `">` + qText + `</option>
` + questionOptions + `
</select>
<textarea name="answer">` + a2.Answer + `</textarea>
<button type="submit">Save</button>
</form>
</td>
</tr>`))
} }
w.Write([]byte(` fmt.Fprint(buf, templates.TableEnd())
</tbody> fmt.Fprintf(buf, `</div></div>`)
</table>
</body> for _, a2 := range answers {
</html>`)) qText := a2.QuestionText
if qText == "" {
qText = questionText[a2.QuestionID]
if qText == "" {
qText = strconv.FormatInt(a2.QuestionID, 10)
}
}
fmt.Fprintf(buf, `
<div id="editA%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 Answer #%d</h3>
<form hx-put="/answers/%d" hx-target="#answerList" hx-swap="innerHTML" class="grid grid-cols-1 gap-3">
<select name="question_id" class="input-industrial text-xs max-w-md"><option value="%d">%s</option>%s</select>
<textarea name="answer" class="input-industrial text-xs" rows="3">%s</textarea>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" onclick="document.getElementById('editA%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, a2.AnswerID, a2.AnswerID, a2.AnswerID, a2.QuestionID, htmlEscape(qText), questionOptions, htmlEscape(a2.Answer), a2.AnswerID)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Answers", "answers")
} }
func (a *App) CreateAnswer(w http.ResponseWriter, r *http.Request) { func (a *App) CreateAnswer(w http.ResponseWriter, r *http.Request) {
@@ -307,8 +402,24 @@ func (a *App) ViewAnswer(w http.ResponseWriter, r *http.Request) {
return return
} }
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Answer", "Response details"))
fmt.Fprintf(buf, `
<div class="max-w-lg animate-slide-up">
<div class="card-industrial p-6">
<div class="text-sm text-zinc-200 leading-relaxed mb-6">%s</div>
<div class="space-y-2 text-xs text-zinc-500 font-mono">
<div>Question ID: %d</div>
<div>Client ID: %d</div>
</div>
<div class="mt-6">
<a href="/answers" class="btn-ghost btn-sm">Back</a>
</div>
</div>
</div>`, htmlEscape(ans.Answer), ans.QuestionID, ans.ClientID)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><body><h1>Answer</h1><p>Question ID: ` + strconv.FormatInt(ans.QuestionID, 10) + `</p><p>Answer: ` + ans.Answer + `</p><a href="/answers">Back</a></body></html>`)) templates.WritePage(w, buf, "Answer", "answers")
} }
func (a *App) UpdateAnswer(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateAnswer(w http.ResponseWriter, r *http.Request) {
@@ -338,7 +449,7 @@ func (a *App) DeleteAnswer(w http.ResponseWriter, r *http.Request) {
a.DB.Exec("DELETE FROM answers WHERE answer_id = ?", id) a.DB.Exec("DELETE FROM answers WHERE answer_id = ?", id)
} }
// --- package-level shims kept for existing tests --- // --- package-level shims ---
func ListQuestions(w http.ResponseWriter, r *http.Request) { func ListQuestions(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListQuestions(w, r) (&App{DB: DB, WAConnector: WAConnector}).ListQuestions(w, r)

View File

@@ -2,9 +2,12 @@ package handlers
import ( import (
"fmt" "fmt"
"html/template"
"math" "math"
"net/http" "net/http"
"time" "time"
"go-crm/internal/templates"
) )
type serviceCount struct { type serviceCount struct {
@@ -25,8 +28,6 @@ type reportData struct {
MonthLabel string MonthLabel string
} }
// MonthlyReport renders the monthly performance report for the current month.
// GET /report
func (a *App) MonthlyReport(w http.ResponseWriter, r *http.Request) { func (a *App) MonthlyReport(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r) accountID, ok := a.requireAuth(w, r)
if !ok { if !ok {
@@ -120,100 +121,99 @@ func (a *App) MonthlyReport(w http.ResponseWriter, r *http.Request) {
var reviewCount int var reviewCount int
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 = ? AND needs_review = 1", clientID).Scan(&reviewCount)
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Monthly Report", fmt.Sprintf("Performance for %s", rd.MonthLabel)))
// Overview KPIs
fmt.Fprintf(buf, `<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">`)
fmt.Fprint(buf, templates.KPICard("Total Leads", fmt.Sprintf("%d", rd.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("Scheduled", fmt.Sprintf("%d", rd.TotalScheduled), fmt.Sprintf("%.1f%% conversion", rd.ConversionRate), "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", rd.TotalRevenue), fmt.Sprintf("%d sales", rd.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.Fprintf(buf, `</div>`)
// Revenue details
fmt.Fprintf(buf, `<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">`)
miniCards := []struct{ label, value string }{
{"Total Sales", fmt.Sprintf("%d", rd.TotalSales)},
{"Average Ticket", fmt.Sprintf("R$ %.2f", rd.AverageTicket)},
{"Pending Review", fmt.Sprintf("%d", reviewCount)},
}
for _, mc := range miniCards {
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>`, mc.label, mc.value)
}
fmt.Fprintf(buf, `</div>`)
// Tables
fmt.Fprintf(buf, `<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">`)
// Service interest table
serviceTable := renderServiceTable(rd.ServiceCounts, rd.TotalLeads)
fmt.Fprint(buf, templates.SectionCard("Leads by Service Interest", "Distribution of service requests", template.HTML(serviceTable)))
// Status table
statusTable := renderServiceTable(rd.StatusCounts, rd.TotalLeads)
fmt.Fprint(buf, templates.SectionCard("Leads by Status", "Pipeline breakdown", template.HTML(statusTable)))
fmt.Fprintf(buf, `</div>`)
// Top services
if len(rd.TopServices) > 0 {
topTable := renderTopServicesTable(rd.TopServices)
fmt.Fprintf(buf, `<div class="mt-6">%s</div>`, templates.SectionCard("Top 5 Services Sold", "Best performing services this month", template.HTML(topTable)))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html> templates.WritePage(w, buf, "Monthly Report", "report")
<html>
<head>
<title>Monthly Report — %s</title>
<style>
body { font-family: sans-serif; padding: 1rem; max-width: 900px; margin: 0 auto; }
h1, h2 { color: #333; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; background: #fafafa; }
.card .value { font-size: 2rem; font-weight: bold; color: #2c7be5; }
.card .label { color: #666; font-size: 0.9rem; margin-top: 0.25rem; }
table { border-collapse: collapse; width: 100%%; margin-bottom: 1.5rem; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; }
nav { margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>Monthly Report — %s</h1>
<nav>
<a href="/">Home</a> |
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
<a href="/leads/all">All Leads</a> |
<a href="/leads/keywords">Keyword Mapping</a>
</nav>
<h2>Overview</h2>
<div class="grid">
<div class="card"><div class="value">%d</div><div class="label">Total Leads</div></div>
<div class="card"><div class="value">%d</div><div class="label">Agendamentos</div></div>
<div class="card"><div class="value">%.1f%%</div><div class="label">Conversion Rate</div></div>
</div>
<h2>Revenue</h2>
<div class="grid">
<div class="card"><div class="value">R$ %.2f</div><div class="label">Total Month Revenue</div></div>
<div class="card"><div class="value">%d</div><div class="label">Total Sales</div></div>
<div class="card"><div class="value">R$ %.2f</div><div class="label">Average Ticket</div></div>
</div>
<h2>Leads by Service Interest</h2>
%s
<h2>Leads by Status</h2>
%s
<h2>Top 5 Services Sold</h2>
%s
</body></html>`,
rd.MonthLabel,
rd.MonthLabel,
reviewCount,
rd.TotalLeads, rd.TotalScheduled, rd.ConversionRate,
rd.TotalRevenue, rd.TotalSales, rd.AverageTicket,
renderServiceTable(rd.ServiceCounts, rd.TotalLeads),
renderServiceTable(rd.StatusCounts, rd.TotalLeads),
renderTopServicesTable(rd.TopServices),
)
} }
func renderServiceTable(counts []serviceCount, total int) string { func renderServiceTable(counts []serviceCount, total int) template.HTML {
if len(counts) == 0 { if len(counts) == 0 {
return `<p style="color:#888">No data for this month.</p>` return template.HTML(`<p class="text-sm text-zinc-500 py-4">No data for this month.</p>`)
} }
out := `<table><thead><tr><th>Name</th><th>Quantity</th><th>%</th></tr></thead><tbody>` out := string(templates.TableStart([]string{"Name", "Quantity", "Percentage"}))
for _, sc := range counts { for _, sc := range counts {
pct := 0.0 pct := 0.0
if total > 0 { if total > 0 {
pct = math.Round(float64(sc.Count)/float64(total)*100*10) / 10 pct = math.Round(float64(sc.Count)/float64(total)*100*10) / 10
} }
out += fmt.Sprintf(`<tr><td>%s</td><td>%d</td><td>%.1f%%</td></tr>`, barWidth := int(pct)
htmlEscape(sc.Name), sc.Count, pct) if barWidth > 100 {
barWidth = 100
} }
out += `</tbody></table>` out += fmt.Sprintf(`<tr>
return out <td class="text-zinc-200">%s</td>
<td class="font-mono text-xs">%d</td>
<td>
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden max-w-[120px]">
<div class="h-full bg-amber-400 rounded-full" style="width:%d%%"></div>
</div>
<span class="text-xs text-zinc-400 font-mono">%.1f%%</span>
</div>
</td>
</tr>`, htmlEscape(sc.Name), sc.Count, barWidth, pct)
}
out += string(templates.TableEnd())
return template.HTML(out)
} }
func renderTopServicesTable(counts []serviceCount) string { func renderTopServicesTable(counts []serviceCount) template.HTML {
if len(counts) == 0 { if len(counts) == 0 {
return `<p style="color:#888">No sales data for this month.</p>` return template.HTML(`<p class="text-sm text-zinc-500 py-4">No sales data for this month.</p>`)
} }
out := `<table><thead><tr><th>Service</th><th>Sales</th></tr></thead><tbody>` out := string(templates.TableStart([]string{"Service", "Sales"}))
for _, sc := range counts { for _, sc := range counts {
out += fmt.Sprintf(`<tr><td>%s</td><td>%d</td></tr>`, htmlEscape(sc.Name), sc.Count) out += fmt.Sprintf(`<tr><td class="text-zinc-200">%s</td><td class="font-mono text-xs text-emerald-400">%d</td></tr>`, htmlEscape(sc.Name), sc.Count)
} }
out += `</tbody></table>` out += string(templates.TableEnd())
return out return template.HTML(out)
} }
// --- package-level shim kept for existing tests --- // MonthlyReport is the package-level shim.
func MonthlyReport(w http.ResponseWriter, r *http.Request) { func MonthlyReport(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).MonthlyReport(w, r) (&App{DB: DB, WAConnector: WAConnector}).MonthlyReport(w, r)
} }

View File

@@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -53,20 +55,43 @@ func (a *App) ListSchedules(w http.ResponseWriter, r *http.Request) {
customerNames := make(map[int64]string) customerNames := make(map[int64]string)
serviceNames := make(map[int64]string) serviceNames := make(map[int64]string)
for _, c := range clients { for _, c := range clients {
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` clientOptions += fmt.Sprintf(`<option value="%d">%s</option>`, c.ClientID, htmlEscape(c.Name))
clientNames[c.ClientID] = c.Name clientNames[c.ClientID] = c.Name
} }
for _, cu := range customers { for _, cu := range customers {
customerOptions += `<option value="` + strconv.FormatInt(cu.CustomerID, 10) + `">` + cu.Name + `</option>` customerOptions += fmt.Sprintf(`<option value="%d">%s</option>`, cu.CustomerID, htmlEscape(cu.Name))
customerNames[cu.CustomerID] = cu.Name customerNames[cu.CustomerID] = cu.Name
} }
for _, s := range services { for _, s := range services {
serviceOptions += `<option value="` + strconv.FormatInt(s.ServiceID, 10) + `">` + s.Name + `</option>` serviceOptions += fmt.Sprintf(`<option value="%d">%s</option>`, s.ServiceID, htmlEscape(s.Name))
serviceNames[s.ServiceID] = s.Name serviceNames[s.ServiceID] = s.Name
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") buf := templates.BufRender()
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script><style>.edit-row{display:none}</style></head><body><h1>Scheduling</h1><button class="btn" onclick="document.getElementById('scheduleForm').style.display='block'">Add Schedule</button><div id="scheduleForm" style="display:none; margin-top:1rem;"><form hx-post="/scheduling" hx-target="#scheduleList"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><select name="customer_id" required><option value="">Select Customer</option>` + customerOptions + `</select><select name="service_id" required><option value="">Select Service</option>` + serviceOptions + `</select><input type="date" name="plan_date"><input type="time" name="time"><select name="status"><option value="pending">Pending</option><option value="confirmed">Confirmed</option><option value="cancelled">Cancelled</option></select><button type="submit">Add</button></form></div><table><thead><tr><th>Client</th><th>Customer</th><th>Service</th><th>Date</th><th>Hour</th><th>Status</th><th>Actions</th></tr></thead><tbody id="scheduleList">`)) actions := `<button type="button" onclick="document.getElementById('scheduleForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Schedule</button>`
fmt.Fprint(buf, templates.PageHeader("Scheduling", "Appointments and calendar", actions))
fmt.Fprintf(buf, `
<div id="scheduleForm" class="hidden mb-6 animate-slide-up">
<div class="card-industrial p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-4">New Appointment</h3>
<form hx-post="/scheduling" hx-target="#scheduleList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<select name="client_id" required class="input-industrial"><option value="">Select Client</option>%s</select>
<select name="customer_id" required class="input-industrial"><option value="">Select Customer</option>%s</select>
<select name="service_id" required class="input-industrial"><option value="">Select Service</option>%s</select>
<input type="date" name="plan_date" class="input-industrial">
<input type="time" name="time" class="input-industrial">
<select name="status" class="input-industrial"><option value="pending">Pending</option><option value="confirmed">Confirmed</option><option value="cancelled">Cancelled</option></select>
<div class="flex items-end"><button type="submit" class="btn-primary">Save</button></div>
</form>
</div>
</div>`, clientOptions, customerOptions, serviceOptions)
if len(schedules) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"/>`, "No appointments scheduled yet."))
} 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{"Client", "Customer", "Service", "Date", "Time", "Status", "Actions"}))
for _, s := range schedules { for _, s := range schedules {
clientName := clientNames[s.ClientID] clientName := clientNames[s.ClientID]
if clientName == "" { if clientName == "" {
@@ -80,9 +105,66 @@ func (a *App) ListSchedules(w http.ResponseWriter, r *http.Request) {
if serviceName == "" { if serviceName == "" {
serviceName = strconv.FormatInt(s.ServiceID, 10) serviceName = strconv.FormatInt(s.ServiceID, 10)
} }
w.Write([]byte(`<tr><td>` + clientName + `</td><td>` + customerName + `</td><td>` + serviceName + `</td><td>` + s.PlanDate + `</td><td>` + s.Time + `</td><td>` + s.Status + `</td><td><a href="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `">View</a><button type="button" onclick="document.getElementById('editSched` + strconv.FormatInt(s.ScheduleID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editSched` + strconv.FormatInt(s.ScheduleID, 10) + `" class="edit-row" style="display:none"><td colspan="7"><form hx-put="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `" hx-target="#scheduleList" hx-swap="innerHTML"><select name="client_id"><option value="` + strconv.FormatInt(s.ClientID, 10) + `">` + clientName + `</option>` + clientOptions + `</select><select name="customer_id"><option value="` + strconv.FormatInt(s.CustomerID, 10) + `">` + customerName + `</option>` + customerOptions + `</select><select name="service_id"><option value="` + strconv.FormatInt(s.ServiceID, 10) + `">` + serviceName + `</option>` + serviceOptions + `</select><input type="date" name="plan_date" value="` + s.PlanDate + `"><input type="time" name="time" value="` + s.Time + `"><select name="status"><option value="` + s.Status + `">` + s.Status + `</option><option value="pending">Pending</option><option value="confirmed">Confirmed</option><option value="cancelled">Cancelled</option></select><button type="submit">Save</button></form></td></tr>`)) statusPill := statusPillForSchedule(s.Status)
fmt.Fprintf(buf, `<tr>
<td class="text-zinc-300 text-sm">%s</td>
<td class="text-zinc-300 text-sm">%s</td>
<td class="text-zinc-400 text-xs">%s</td>
<td class="font-mono text-xs text-zinc-300">%s</td>
<td class="font-mono text-xs text-zinc-300">%s</td>
<td>%s</td>
<td>
<div class="flex items-center gap-2">
<a href="/scheduling/%d" class="btn-ghost btn-sm">View</a>
<button type="button" onclick="document.getElementById('editSched%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/scheduling/%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(clientName), htmlEscape(customerName), htmlEscape(serviceName), htmlEscape(s.PlanDate), htmlEscape(s.Time), statusPill, s.ScheduleID, s.ScheduleID, s.ScheduleID)
}
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
for _, s := range schedules {
clientName := clientNames[s.ClientID]
customerName := customerNames[s.CustomerID]
serviceName := serviceNames[s.ServiceID]
fmt.Fprintf(buf, `
<div id="editSched%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 Appointment #%d</h3>
<form hx-put="/scheduling/%d" hx-target="#scheduleList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<select name="client_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<select name="customer_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<select name="service_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<input type="date" name="plan_date" value="%s" class="input-industrial text-xs">
<input type="time" name="time" value="%s" class="input-industrial text-xs">
<select name="status" class="input-industrial text-xs"><option value="%s">%s</option><option value="pending">Pending</option><option value="confirmed">Confirmed</option><option value="cancelled">Cancelled</option></select>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" onclick="document.getElementById('editSched%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, s.ScheduleID, s.ScheduleID, s.ScheduleID, s.ClientID, htmlEscape(clientName), clientOptions, s.CustomerID, htmlEscape(customerName), customerOptions, s.ServiceID, htmlEscape(serviceName), serviceOptions, htmlEscape(s.PlanDate), htmlEscape(s.Time), s.Status, s.Status, s.ScheduleID)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Scheduling", "scheduling")
}
func statusPillForSchedule(status string) string {
switch status {
case "confirmed":
return `<span class="pill pill-emerald">confirmed</span>`
case "cancelled":
return `<span class="pill pill-rose">cancelled</span>`
default:
return `<span class="pill pill-amber">pending</span>`
} }
w.Write([]byte(`</tbody></table></body></html>`))
} }
func (a *App) CreateSchedule(w http.ResponseWriter, r *http.Request) { func (a *App) CreateSchedule(w http.ResponseWriter, r *http.Request) {
@@ -127,8 +209,35 @@ func (a *App) ViewSchedule(w http.ResponseWriter, r *http.Request) {
return return
} }
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Appointment", "Schedule details"))
statusPill := statusPillForSchedule(sch.Status)
fmt.Fprintf(buf, `
<div class="max-w-lg animate-slide-up">
<div class="card-industrial p-6">
<div class="flex items-center gap-2 mb-6">%s</div>
<div class="space-y-3">
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Date</span>
<span class="text-sm font-mono text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Time</span>
<span class="text-sm font-mono text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-3">
<span class="text-xs font-mono uppercase text-zinc-500">Client / Customer / Service</span>
<span class="text-sm text-zinc-300 text-right font-mono">%d / %d / %d</span>
</div>
</div>
<div class="mt-6">
<a href="/scheduling" class="btn-ghost btn-sm">Back</a>
</div>
</div>
</div>`, statusPill, htmlEscape(sch.PlanDate), htmlEscape(sch.Time), sch.ClientID, sch.CustomerID, sch.ServiceID)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><body><h1>Schedule</h1><p>Client ID: ` + strconv.FormatInt(sch.ClientID, 10) + `</p><p>Customer ID: ` + strconv.FormatInt(sch.CustomerID, 10) + `</p><p>Service ID: ` + strconv.FormatInt(sch.ServiceID, 10) + `</p><p>Date: ` + sch.PlanDate + `</p><p>Time: ` + sch.Time + `</p><p>Status: ` + sch.Status + `</p><a href="/scheduling">Back</a></body></html>`)) templates.WritePage(w, buf, "Appointment", "scheduling")
} }
func (a *App) UpdateSchedule(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateSchedule(w http.ResponseWriter, r *http.Request) {
@@ -160,7 +269,7 @@ func (a *App) DeleteSchedule(w http.ResponseWriter, r *http.Request) {
a.DB.Exec("DELETE FROM scheduling WHERE schedule_id = ?", id) a.DB.Exec("DELETE FROM scheduling WHERE schedule_id = ?", id)
} }
// --- package-level shims kept for existing tests --- // --- package-level shims ---
func ListSchedules(w http.ResponseWriter, r *http.Request) { func ListSchedules(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListSchedules(w, r) (&App{DB: DB, WAConnector: WAConnector}).ListSchedules(w, r)

View File

@@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"go-crm/internal/db" "go-crm/internal/db"
"go-crm/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -37,15 +39,75 @@ func (a *App) ListServices(w http.ResponseWriter, r *http.Request) {
var clientOptions string var clientOptions string
for _, c := range clients { for _, c := range clients {
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` clientOptions += fmt.Sprintf(`<option value="%d">%s</option>`, c.ClientID, htmlEscape(c.Name))
}
buf := templates.BufRender()
actions := `<button type="button" onclick="document.getElementById('serviceForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Service</button>`
fmt.Fprint(buf, templates.PageHeader("Services", "Your service catalog and pricing", actions))
fmt.Fprintf(buf, `
<div id="serviceForm" class="hidden mb-6 animate-slide-up">
<div class="card-industrial p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-4">New Service</h3>
<form hx-post="/services" hx-target="#serviceList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<input type="text" name="name" placeholder="Service Name" required class="input-industrial">
<input type="number" name="price" placeholder="Price" step="0.01" class="input-industrial">
<input type="text" name="duration" placeholder="Duration" class="input-industrial">
<select name="client_id" required class="input-industrial"><option value="">Select Client</option>%s</select>
<textarea name="description" placeholder="Description" class="input-industrial sm:col-span-2 lg:col-span-3" rows="2"></textarea>
<div class="flex items-end"><button type="submit" class="btn-primary">Save</button></div>
</form>
</div>
</div>`, clientOptions)
if len(services) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37 1.608.536 3.135-.493 3.35-2.572zM15 12a3 3 0 11-6 0 3 3 0 016 0z"/>`, "No services yet. Add your first service to the catalog."))
} 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", "Price", "Duration", "Actions"}))
for _, s := range services {
fmt.Fprintf(buf, `<tr>
<td class="font-medium text-zinc-200">%s</td>
<td class="font-mono text-sm text-emerald-400">R$ %.2f</td>
<td class="text-xs text-zinc-400">%s</td>
<td>
<div class="flex items-center gap-2">
<a href="/services/%d" class="btn-ghost btn-sm">View</a>
<button type="button" onclick="document.getElementById('editServ%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/services/%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(s.Name), s.Price, htmlEscape(s.Duration), s.ServiceID, s.ServiceID, s.ServiceID)
}
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
for _, s := range services {
fmt.Fprintf(buf, `
<div id="editServ%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 %s</h3>
<form hx-put="/services/%d" hx-target="#serviceList" hx-swap="innerHTML" 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">
<input type="number" name="price" value="%.2f" step="0.01" class="input-industrial text-xs">
<input type="text" name="duration" value="%s" class="input-industrial text-xs">
<select name="client_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
<textarea name="description" class="input-industrial text-xs sm:col-span-2 lg:col-span-3" rows="2">%s</textarea>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" onclick="document.getElementById('editServ%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
</div>
</form>
</div>
</div>`, s.ServiceID, htmlEscape(s.Name), s.ServiceID, htmlEscape(s.Name), s.Price, htmlEscape(s.Duration), s.ClientID, htmlEscape(s.Name), clientOptions, htmlEscape(s.Description), s.ServiceID)
}
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Services</h1><button class="btn" onclick="document.getElementById('serviceForm').style.display='block'">Add Service</button><div id="serviceForm" style="display:none; margin-top:1rem;"><form hx-post="/services" hx-target="#serviceList"><input type="text" name="name" placeholder="Service Name" required><input type="number" name="price" placeholder="Price" step="0.01"><textarea name="description" placeholder="Description"></textarea><input type="text" name="duration" placeholder="Duration"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><button type="submit">Add</button></form></div><table><thead><tr><th>Name</th><th>Price</th><th>Duration</th><th>Actions</th></tr></thead><tbody id="serviceList">`)) templates.WritePage(w, buf, "Services", "services")
for _, s := range services {
w.Write([]byte(`<tr><td>` + s.Name + `</td><td>` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `</td><td>` + s.Duration + `</td><td><a href="/services/` + strconv.FormatInt(s.ServiceID, 10) + `">View</a><button type="button" onclick="document.getElementById('editServ` + strconv.FormatInt(s.ServiceID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/services/` + strconv.FormatInt(s.ServiceID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editServ` + strconv.FormatInt(s.ServiceID, 10) + `" style="display:none"><td colspan="4"><form hx-put="/services/` + strconv.FormatInt(s.ServiceID, 10) + `" hx-target="#serviceList" hx-swap="innerHTML"><input type="text" name="name" value="` + s.Name + `"><input type="number" name="price" value="` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `"><textarea name="description">` + s.Description + `</textarea><input type="text" name="duration" value="` + s.Duration + `"><select name="client_id"><option value="` + strconv.FormatInt(s.ClientID, 10) + `">` + s.Name + `</option>` + clientOptions + `</select><button type="submit">Save</button></form></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
} }
func (a *App) CreateService(w http.ResponseWriter, r *http.Request) { func (a *App) CreateService(w http.ResponseWriter, r *http.Request) {
@@ -96,8 +158,33 @@ func (a *App) ViewService(w http.ResponseWriter, r *http.Request) {
return return
} }
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader(htmlEscape(s.Name), "Service details"))
fmt.Fprintf(buf, `
<div class="max-w-lg animate-slide-up">
<div class="card-industrial p-6">
<div class="space-y-4">
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Price</span>
<span class="text-lg font-mono font-semibold text-emerald-400">R$ %.2f</span>
</div>
<div class="flex justify-between py-3 border-b border-white/[0.04]">
<span class="text-xs font-mono uppercase text-zinc-500">Duration</span>
<span class="text-sm text-zinc-300">%s</span>
</div>
<div class="flex justify-between py-3">
<span class="text-xs font-mono uppercase text-zinc-500">Description</span>
<span class="text-sm text-zinc-300 text-right max-w-xs">%s</span>
</div>
</div>
<div class="mt-6">
<a href="/services" class="btn-ghost btn-sm">Back</a>
</div>
</div>
</div>`, s.Price, htmlEscape(s.Duration), htmlEscape(s.Description))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><body><h1>` + s.Name + `</h1><p>Price: ` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `</p><p>Description: ` + s.Description + `</p><p>Duration: ` + s.Duration + `</p><a href="/services">Back</a></body></html>`)) templates.WritePage(w, buf, s.Name, "services")
} }
func (a *App) UpdateService(w http.ResponseWriter, r *http.Request) { func (a *App) UpdateService(w http.ResponseWriter, r *http.Request) {
@@ -128,7 +215,7 @@ func (a *App) DeleteService(w http.ResponseWriter, r *http.Request) {
a.DB.Exec("DELETE FROM services WHERE service_id = ?", id) a.DB.Exec("DELETE FROM services WHERE service_id = ?", id)
} }
// --- package-level shims kept for existing tests --- // --- package-level shims ---
func ListServices(w http.ResponseWriter, r *http.Request) { func ListServices(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListServices(w, r) (&App{DB: DB, WAConnector: WAConnector}).ListServices(w, r)

View File

@@ -111,7 +111,7 @@ func TestListClientsWhatsAppStatusIndicators(t *testing.T) {
t.Error("expected Connect link for clients without active connection") t.Error("expected Connect link for clients without active connection")
} }
greenDot := `color:#28a745` greenDot := `bg-emerald-400`
if !strings.Contains(body, greenDot) { if !strings.Contains(body, greenDot) {
t.Error("expected green dot for connected client") t.Error("expected green dot for connected client")
} }

View File

@@ -0,0 +1,490 @@
package templates
import (
"bytes"
"fmt"
"html/template"
"io"
"strings"
)
// Page renders a full HTML page with the industrial terminal layout.
type Page struct {
Title string
ActiveNav string
Content template.HTML
ExtraHead template.HTML
}
// NavItem defines a navigation entry.
type NavItem struct {
Label string
Href string
Icon string
ID string
}
var navItems = []NavItem{
{Label: "Dashboard", Href: "/", Icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", ID: "dashboard"},
{Label: "Review Queue", Href: "/leads/review", Icon: "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", ID: "review"},
{Label: "All Leads", Href: "/leads/all", Icon: "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 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z", ID: "leads"},
{Label: "Report", Href: "/report", Icon: "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", ID: "report"},
{Label: "Clients", Href: "/clients", Icon: "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", ID: "clients"},
{Label: "Customers", Href: "/customers", Icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", ID: "customers"},
{Label: "Services", Href: "/services", Icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37 1.608.536 3.135-.493 3.35-2.572zM15 12a3 3 0 11-6 0 3 3 0 016 0z", ID: "services"},
{Label: "Scheduling", Href: "/scheduling", Icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", ID: "scheduling"},
{Label: "Payments", Href: "/payments", Icon: "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", ID: "payments"},
{Label: "Questions", Href: "/questions", Icon: "M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", ID: "questions"},
{Label: "Answers", Href: "/answers", Icon: "M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z", ID: "answers"},
}
const layoutTemplate = `<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — go-crm</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/loading-states.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
zinc: { 950: '#0a0a0f', 900: '#111118', 850: '#15151e', 800: '#1a1a24', 750: '#1e1e2a', 700: '#272736', 600: '#3f3f50', 500: '#5a5a6e', 400: '#7a7a8f', 300: '#a1a1b3', 200: '#c4c4d4', 100: '#e2e2eb', 50: '#f4f4f8' },
amber: { 450: '#d97706', 400: '#f59e0b', 300: '#fbbf24' },
emerald: { 450: '#059669', 400: '#10b981', 300: '#34d399' },
rose: { 450: '#e11d48', 400: '#f43f5e', 300: '#fb7185' },
sky: { 450: '#0284c7', 400: '#0ea5e9', 300: '#38bdf8' },
},
fontFamily: {
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
sans: ['"Inter"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
},
}
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Industrial Terminal Aesthetic */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #111118;
--bg-tertiary: #1a1a24;
--bg-elevated: #1e1e2a;
--border-subtle: rgba(255,255,255,0.06);
--border-medium: rgba(255,255,255,0.1);
--border-strong: rgba(255,255,255,0.15);
--text-primary: #e2e2eb;
--text-secondary: #a1a1b3;
--text-muted: #7a7a8f;
--accent-amber: #f59e0b;
--accent-emerald: #10b981;
--accent-rose: #f43f5e;
--accent-sky: #0ea5e9;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, sans-serif;
}
/* Grid overlay */
.grid-overlay {
background-image:
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
background-size: 40px 40px;
}
/* Subtle noise texture */
.noise-overlay::before {
content: '';
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
opacity: 0.025;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
z-index: 9999;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--bg-elevated); }
/* HTMX transitions */
.htmx-swapping { opacity: 0.5; transition: opacity 200ms ease; }
.htmx-settling { opacity: 0; animation: fadeIn 250ms ease forwards; }
@keyframes fadeIn { to { opacity: 1; } }
/* Loading spinner */
.htmx-request .spinner { display: inline-block !important; }
.spinner { display: none; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.1); border-top-color: var(--accent-amber); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Table styles */
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; }
.data-table th { background: var(--bg-secondary); color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 500; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-medium); text-align: left; white-space: nowrap; }
.data-table td { padding: 0.875rem 1rem; border-bottom: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 0.875rem; vertical-align: middle; }
.data-table tbody tr:hover td { background: rgba(255,255,255,0.02); }
.data-table tbody tr:last-child td { border-bottom: none; }
/* Form inputs */
.input-industrial {
background: var(--bg-secondary);
border: 1px solid var(--border-medium);
color: var(--text-primary);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 150ms, box-shadow 150ms;
outline: none;
width: 100%;
}
.input-industrial:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 2px rgba(245,158,11,0.1); }
.input-industrial::placeholder { color: var(--text-muted); }
select.input-industrial { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%237a7a8f'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.5rem center; background-size: 1.25rem; padding-right: 2.5rem; }
/* Buttons */
.btn-primary {
background: var(--accent-amber);
color: #0a0a0f;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.025em;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: all 150ms;
font-family: 'JetBrains Mono', monospace;
display: inline-flex; align-items: center; gap: 0.5rem;
}
.btn-primary:hover { background: #fbbf24; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245,158,11,0.2); }
.btn-primary:active { transform: translateY(0); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-medium);
font-weight: 500;
font-size: 0.8rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 150ms;
display: inline-flex; align-items: center; gap: 0.5rem;
}
.btn-ghost:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); border-color: var(--border-strong); }
.btn-danger {
background: rgba(244,63,94,0.1);
color: var(--accent-rose);
border: 1px solid rgba(244,63,94,0.2);
font-weight: 500;
font-size: 0.8rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 150ms;
}
.btn-danger:hover { background: rgba(244,63,94,0.2); }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
/* Cards */
.card-industrial {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 0.5rem;
position: relative;
overflow: hidden;
}
.card-industrial::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
}
/* Status pills */
.pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.pill-amber { background: rgba(245,158,11,0.1); color: var(--accent-amber); border: 1px solid rgba(245,158,11,0.15); }
.pill-emerald { background: rgba(16,185,129,0.1); color: var(--accent-emerald); border: 1px solid rgba(16,185,129,0.15); }
.pill-rose { background: rgba(244,63,94,0.1); color: var(--accent-rose); border: 1px solid rgba(244,63,94,0.15); }
.pill-sky { background: rgba(14,165,233,0.1); color: var(--accent-sky); border: 1px solid rgba(14,165,233,0.15); }
.pill-zinc { background: rgba(255,255,255,0.05); color: var(--text-muted); border: 1px solid var(--border-medium); }
/* Nav */
.nav-link {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 0.375rem;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 500;
transition: all 150ms;
text-decoration: none;
border: 1px solid transparent;
}
.nav-link:hover { background: rgba(255,255,255,0.03); color: var(--text-secondary); }
.nav-link.active { background: rgba(245,158,11,0.08); color: var(--accent-amber); border-color: rgba(245,158,11,0.15); }
.nav-link svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Animation */
@keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.animate-slide-up { animation: slideUp 0.4s ease forwards; }
.stagger-1 { animation-delay: 0.05s; opacity: 0; }
.stagger-2 { animation-delay: 0.1s; opacity: 0; }
.stagger-3 { animation-delay: 0.15s; opacity: 0; }
.stagger-4 { animation-delay: 0.2s; opacity: 0; }
.stagger-5 { animation-delay: 0.25s; opacity: 0; }
/* Mobile */
@media (max-width: 1024px) {
.sidebar { transform: translateX(-100%); transition: transform 300ms ease; }
.sidebar.open { transform: translateX(0); }
.sidebar-backdrop { display: none; }
.sidebar.open + .sidebar-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 30; }
}
</style>
{{.ExtraHead}}
</head>
<body class="noise-overlay grid-overlay min-h-screen antialiased">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside id="sidebar" class="sidebar fixed lg:static inset-y-0 left-0 w-64 bg-zinc-900 border-r border-white/[0.06] z-40 flex flex-col">
<div class="p-5 border-b border-white/[0.06]">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded bg-amber-400/10 border border-amber-400/20 flex items-center justify-center">
<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="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<div class="font-mono text-sm font-bold text-zinc-100 tracking-tight">go-crm</div>
<div class="font-mono text-[10px] text-zinc-500 uppercase tracking-wider">v2.0.0-industrial</div>
</div>
</div>
</div>
<nav class="flex-1 overflow-y-auto p-3 space-y-0.5">
{{range .NavItems}}
<a href="{{.Href}}" class="nav-link {{if .Active}}active{{end}}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="{{.Icon}}"/></svg>
<span>{{.Label}}</span>
</a>
{{end}}
</nav>
<div class="p-4 border-t border-white/[0.06]">
<a href="/auth/account" class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/[0.03] transition-colors">
<div class="w-8 h-8 rounded-full bg-zinc-800 border border-white/[0.08] flex items-center justify-center">
<svg class="w-4 h-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-zinc-300 truncate">Account</div>
<div class="text-[10px] text-zinc-500">Settings</div>
</div>
</a>
<form method="POST" action="/auth/logout" class="mt-2">
<button type="submit" class="w-full flex items-center gap-2 px-3 py-2 text-xs text-zinc-500 hover:text-rose-400 hover:bg-rose-400/5 rounded-lg transition-colors font-mono">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Sign Out
</button>
</form>
</div>
</aside>
<div class="sidebar-backdrop lg:hidden" onclick="document.getElementById('sidebar').classList.remove('open')"></div>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0 lg:ml-0">
<!-- Top bar -->
<header class="h-14 bg-zinc-900/80 backdrop-blur border-b border-white/[0.06] flex items-center justify-between px-4 lg:px-6 sticky top-0 z-30">
<div class="flex items-center gap-3">
<button onclick="document.getElementById('sidebar').classList.toggle('open')" class="lg:hidden p-2 -ml-2 text-zinc-400 hover:text-zinc-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<h1 class="font-mono text-sm font-semibold text-zinc-200 tracking-tight">{{.Title}}</h1>
<div class="spinner ml-2"></div>
</div>
<div class="flex items-center gap-3">
<a href="/leads/keywords" class="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs font-mono text-zinc-400 bg-zinc-800/50 border border-white/[0.06] rounded hover:text-amber-400 hover:border-amber-400/20 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
Keywords
</a>
</div>
</header>
<!-- Page content -->
<main class="flex-1 p-4 lg:p-6 overflow-x-hidden">
{{.Content}}
</main>
</div>
</div>
</body>
</html>`
// NavItemRender is used in the layout template.
type NavItemRender struct {
Label string
Href string
Icon string
Active bool
}
// RenderPage writes a complete HTML page with the industrial layout.
func RenderPage(w io.Writer, title, activeNav string, content template.HTML) error {
var items []NavItemRender
for _, ni := range navItems {
items = append(items, NavItemRender{
Label: ni.Label,
Href: ni.Href,
Icon: ni.Icon,
Active: ni.ID == activeNav,
})
}
tmpl, err := template.New("layout").Parse(layoutTemplate)
if err != nil {
return err
}
return tmpl.Execute(w, struct {
Title string
NavItems []NavItemRender
Content template.HTML
ExtraHead template.HTML
}{
Title: title,
NavItems: items,
Content: content,
})
}
// ---------------------------------------------------------------------------
// Page Sections / Helpers
// ---------------------------------------------------------------------------
// PageHeader generates a page header with title and optional action buttons.
func PageHeader(title, subtitle string, actions ...string) template.HTML {
var actionHTML string
if len(actions) > 0 {
actionHTML = `<div class="flex items-center gap-2">` + strings.Join(actions, "") + `</div>`
}
return template.HTML(fmt.Sprintf(`
<div class="mb-6 animate-slide-up">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<h1 class="text-xl font-bold text-zinc-100 tracking-tight">%s</h1>
<p class="text-sm text-zinc-500 mt-1">%s</p>
</div>
%s
</div>
</div>`, template.HTMLEscapeString(title), template.HTMLEscapeString(subtitle), actionHTML))
}
// KPI card for dashboards.
func KPICard(label, value, trend, colorClass, iconSVG string, stagger int) template.HTML {
borderColor := "border-white/[0.06]"
textColor := "text-zinc-300"
switch colorClass {
case "amber":
borderColor = "border-amber-400/15"
textColor = "text-amber-400"
case "emerald":
borderColor = "border-emerald-400/15"
textColor = "text-emerald-400"
case "rose":
borderColor = "border-rose-400/15"
textColor = "text-rose-400"
case "sky":
borderColor = "border-sky-400/15"
textColor = "text-sky-400"
}
trendHTML := ""
if trend != "" {
trendHTML = fmt.Sprintf(`<span class="text-[10px] font-mono text-zinc-500 mt-1">%s</span>`, template.HTMLEscapeString(trend))
}
return template.HTML(fmt.Sprintf(`
<div class="card-industrial p-5 border %s animate-slide-up stagger-%d">
<div class="flex items-start justify-between">
<div>
<div class="text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-2">%s</div>
<div class="text-2xl font-mono font-bold %s">%s</div>
%s
</div>
<div class="p-2 rounded-lg bg-white/[0.03] border border-white/[0.06]">
<svg class="w-5 h-5 %s" fill="none" stroke="currentColor" viewBox="0 0 24 24">%s</svg>
</div>
</div>
</div>`, borderColor, stagger,
template.HTMLEscapeString(label),
textColor,
template.HTMLEscapeString(value),
trendHTML,
textColor,
iconSVG))
}
// TableStart begins a data table.
func TableStart(headers []string) template.HTML {
var ths string
for _, h := range headers {
ths += fmt.Sprintf(`<th>%s</th>`, template.HTMLEscapeString(h))
}
return template.HTML(fmt.Sprintf(`<table class="data-table"><thead><tr>%s</tr></thead><tbody>`, ths))
}
// TableEnd closes a data table.
func TableEnd() template.HTML {
return template.HTML(`</tbody></table>`)
}
// EmptyState shows when no data exists.
func EmptyState(icon, message string) template.HTML {
return template.HTML(fmt.Sprintf(`
<div class="flex flex-col items-center justify-center py-16 text-center animate-slide-up">
<div class="w-16 h-16 rounded-full bg-white/[0.03] border border-white/[0.06] flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">%s</svg>
</div>
<p class="text-zinc-500 text-sm">%s</p>
</div>`, icon, template.HTMLEscapeString(message)))
}
// SectionCard wraps content in a card with title.
func SectionCard(title, subtitle string, content template.HTML) template.HTML {
return template.HTML(fmt.Sprintf(`
<div class="card-industrial animate-slide-up">
<div class="px-5 py-4 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold text-zinc-200">%s</h2>
<p class="text-xs text-zinc-500 mt-0.5">%s</p>
</div>
<div class="p-5">%s</div>
</div>`, template.HTMLEscapeString(title), template.HTMLEscapeString(subtitle), content))
}
// ---------------------------------------------------------------------------
// Render helpers that write to a buffer then to a ResponseWriter
// ---------------------------------------------------------------------------
// BufRender starts a bytes.Buffer for collecting HTML content.
func BufRender() *bytes.Buffer {
return &bytes.Buffer{}
}
// WritePage renders the full page from a buffer to a ResponseWriter.
func WritePage(w io.Writer, buf *bytes.Buffer, title, activeNav string) error {
return RenderPage(w, title, activeNav, template.HTML(buf.String()))
}
// ---------------------------------------------------------------------------
// Convenience: write raw HTML directly to ResponseWriter with layout
// ---------------------------------------------------------------------------
// WriteHTMLPage takes an io.Writer, writes the layout wrapping the given HTML string.
func WriteHTMLPage(w io.Writer, title, activeNav, html string) error {
return RenderPage(w, title, activeNav, template.HTML(html))
}