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:
@@ -2,9 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -13,24 +16,38 @@ import (
|
||||
var DB *sql.DB
|
||||
|
||||
func (a *App) SignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
content := fmt.Sprintf(`
|
||||
<div class="min-h-[60vh] flex items-center justify-center">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="text-center mb-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">
|
||||
<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>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100 tracking-tight">go-crm</h1>
|
||||
<p class="text-sm text-zinc-500 mt-1">Create your account</p>
|
||||
</div>
|
||||
<div class="card-industrial p-6">
|
||||
<form method="POST" action="/auth/signup" class="space-y-4">
|
||||
<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">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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>`)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Sign Up</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sign Up</h1>
|
||||
<form method="POST" action="/auth/signup">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
<p>Already have an account? <a href="/auth/login">Login</a></p>
|
||||
</body>
|
||||
</html>`))
|
||||
templates.WriteHTMLPage(w, "Sign Up", "", content)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Use a transaction so account + client + session are atomic.
|
||||
tx, err := a.DB.Begin()
|
||||
if err != nil {
|
||||
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) {
|
||||
content := fmt.Sprintf(`
|
||||
<div class="min-h-[60vh] flex items-center justify-center">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="text-center mb-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">
|
||||
<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>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100 tracking-tight">go-crm</h1>
|
||||
<p class="text-sm text-zinc-500 mt-1">Sign in to your workspace</p>
|
||||
</div>
|
||||
<div class="card-industrial p-6">
|
||||
<form method="POST" action="/auth/login" class="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>`)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Login</h1>
|
||||
<form method="POST" action="/auth/login">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<p>Don't have an account? <a href="/auth/signup">Sign Up</a></p>
|
||||
</body>
|
||||
</html>`))
|
||||
templates.WriteHTMLPage(w, "Sign In", "", content)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`
|
||||
%s
|
||||
<div class="max-w-lg animate-slide-up">
|
||||
<div class="card-industrial p-6">
|
||||
<form method="POST" action="/auth/account" class="space-y-4">
|
||||
<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>
|
||||
</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")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Account</title></head><body><h1>Account Settings</h1><form method="POST" action="/auth/account">
|
||||
<p>Name: <input type="text" name="name" value="` + name + `"></p>
|
||||
<p>Email: <input type="email" value="` + email + `" disabled></p>
|
||||
<p>New Password: <input type="password" name="password" placeholder="Leave blank to keep current"></p>
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<a href="/">Back to Dashboard</a></body></html>`))
|
||||
templates.WriteHTMLPage(w, "Account", "", content)
|
||||
}
|
||||
|
||||
func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -28,75 +30,94 @@ func (a *App) ListClients(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Clients</title>
|
||||
<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; }
|
||||
.edit-row { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Clients</h1>
|
||||
<button class="btn" onclick="document.getElementById('clientForm').style.display='block'">Add Client</button>
|
||||
<div id="clientForm" style="display:none; margin-top:1rem;">
|
||||
<form hx-post="/clients" hx-target="#clientList" hx-swap="innerHTML">
|
||||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="tel" name="phone" placeholder="Phone">
|
||||
<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>
|
||||
buf := templates.BufRender()
|
||||
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>`
|
||||
fmt.Fprint(buf, templates.PageHeader("Clients", "Manage your business clients", actions))
|
||||
|
||||
// Add form
|
||||
fmt.Fprintf(buf, `
|
||||
<div id="clientForm" 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 Client</h3>
|
||||
<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">
|
||||
<input type="text" name="name" placeholder="Name" required class="input-industrial">
|
||||
<input type="tel" name="phone" placeholder="Phone" class="input-industrial">
|
||||
<input type="email" name="email" placeholder="Email" class="input-industrial">
|
||||
<input type="text" name="address" placeholder="Address" class="input-industrial">
|
||||
<textarea name="notes" placeholder="Notes" class="input-industrial sm:col-span-2 lg:col-span-2" rows="2"></textarea>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="btn-primary">Save Client</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Phone</th><th>WhatsApp</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="clientList">
|
||||
`))
|
||||
for _, c := range clients {
|
||||
var whatsappCell string
|
||||
connected := false
|
||||
if c.WhatsAppNumber != "" && a.WAConnector != nil {
|
||||
connected, _ = a.WAConnector.IsConnected(r.Context(), c.ClientID)
|
||||
</div>`)
|
||||
|
||||
if len(clients) == 0 {
|
||||
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."))
|
||||
} 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 {
|
||||
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
|
||||
if a.WAConnector != nil {
|
||||
connected, _ = a.WAConnector.IsConnected(r.Context(), c.ClientID)
|
||||
}
|
||||
if connected {
|
||||
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 {
|
||||
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 {
|
||||
whatsappCell = fmt.Sprintf(`<a href="/leads/connect?client_id=%d" class="btn-ghost btn-sm">Connect</a>`, c.ClientID)
|
||||
}
|
||||
}
|
||||
created := time.Unix(c.CreatedAt, 0).Format("02/01/2006")
|
||||
fmt.Fprintf(buf, `<tr>
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/clients/%d" class="btn-ghost btn-sm">View</a>
|
||||
<button type="button" onclick="document.getElementById('editForm%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), whatsappCell, created, c.ClientID, c.ClientID, c.ClientID)
|
||||
}
|
||||
if connected {
|
||||
whatsappCell = c.WhatsAppNumber + ` <span style="color:#28a745">●</span>`
|
||||
} else if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
|
||||
whatsappCell = c.WhatsAppNumber + ` <span style="color:#dc3545">○</span> <a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Reconnect</a>`
|
||||
} else {
|
||||
whatsappCell = `<a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Connect</a>`
|
||||
fmt.Fprint(buf, templates.TableEnd())
|
||||
fmt.Fprintf(buf, `</div></div>`)
|
||||
|
||||
// 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.Write([]byte(`<tr>
|
||||
<td>` + c.Name + `</td>
|
||||
<td>` + c.Phone + `</td>
|
||||
<td>` + whatsappCell + `</td>
|
||||
<td>
|
||||
<a href="/clients/` + strconv.FormatInt(c.ClientID, 10) + `">View</a>
|
||||
<button type="button" onclick="document.getElementById('editForm` + strconv.FormatInt(c.ClientID, 10) + `').style.display='block'">Edit</button>
|
||||
<form method="DELETE" style="display:inline" hx-delete="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="closest tr">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
<tr id="editForm` + strconv.FormatInt(c.ClientID, 10) + `" class="edit-row"><td colspan="4">
|
||||
<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>
|
||||
</tr>`))
|
||||
}
|
||||
w.Write([]byte(`</tbody></table>
|
||||
<p><a href="/">Back to Home</a></p>
|
||||
</body></html>`))
|
||||
|
||||
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) {
|
||||
@@ -121,7 +142,6 @@ func (a *App) CreateClient(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
@@ -138,43 +158,76 @@ func (a *App) ViewClient(w http.ResponseWriter, r *http.Request) {
|
||||
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 != "" {
|
||||
connected := false
|
||||
if a.WAConnector != nil {
|
||||
connected, _ = a.WAConnector.IsConnected(r.Context(), client.ClientID)
|
||||
}
|
||||
status := "Not connected"
|
||||
style := "color:#999"
|
||||
statusPill := `<span class="pill pill-rose">Disconnected</span>`
|
||||
if connected {
|
||||
status = "Connected"
|
||||
style = "color:#28a745"
|
||||
statusPill = `<span class="pill pill-emerald">Connected</span>`
|
||||
} else if client.WhatsAppConnected == 1 {
|
||||
status = "Disconnected (was connected)"
|
||||
style = "color:#dc3545"
|
||||
statusPill = `<span class="pill pill-amber">Stale</span>`
|
||||
}
|
||||
whatsappSection = `
|
||||
<p>WhatsApp: <strong>` + client.WhatsAppNumber + `</strong> <span style="` + style + `">(` + status + `)</span></p>
|
||||
<p><a href="/leads/connect?client_id=` + strconv.FormatInt(client.ClientID, 10) + `">Reconnect WhatsApp</a></p>`
|
||||
waSection = fmt.Sprintf(`
|
||||
<div class="card-industrial p-5 mb-6">
|
||||
<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 {
|
||||
whatsappSection = `
|
||||
<p>WhatsApp: Not configured <a href="/leads/connect?client_id=` + strconv.FormatInt(client.ClientID, 10) + `">Connect</a></p>`
|
||||
waSection = fmt.Sprintf(`
|
||||
<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>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<h1>` + client.Name + `</h1>
|
||||
<p>Client ID: ` + strconv.FormatInt(client.ClientID, 10) + `</p>
|
||||
<p>Phone: ` + client.Phone + `</p>
|
||||
<p>Email: ` + client.Email + `</p>
|
||||
<p>Address: ` + client.Address + `</p>
|
||||
<p>Notes: ` + client.Notes + `</p>` + whatsappSection + `
|
||||
<a href="/clients">Back</a>
|
||||
</body></html>`))
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-slide-up">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card-industrial p-5">
|
||||
<h3 class="text-sm font-semibold text-zinc-200 mb-4">Details</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between py-2 border-b border-white/[0.04]">
|
||||
<span class="text-xs text-zinc-500 font-mono uppercase">Phone</span>
|
||||
<span class="text-sm text-zinc-300 font-mono">%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">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) {
|
||||
@@ -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.Write([]byte("OK"))
|
||||
w.Write([]byte(""))
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests ---
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/config"
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -38,15 +40,76 @@ func (a *App) ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var clientOptions string
|
||||
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.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">`))
|
||||
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>`))
|
||||
templates.WritePage(w, buf, "Customers", "customers")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.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) {
|
||||
@@ -165,7 +253,7 @@ func (a *App) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).ListCustomers(w, r)
|
||||
|
||||
@@ -46,4 +46,4 @@ func TestCreateCustomerInternalSecret(t *testing.T) {
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusFound {
|
||||
t.Fatalf("expected success with internal secret, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
)
|
||||
|
||||
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -14,72 +19,210 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var clientID int64
|
||||
a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
|
||||
|
||||
// Gather metrics
|
||||
var reviewCount int
|
||||
var totalLeads int
|
||||
var totalScheduled int
|
||||
var totalRevenue float64
|
||||
var totalSales int
|
||||
var totalCustomers int
|
||||
var totalServices int
|
||||
var pendingSchedules int
|
||||
|
||||
if clientID > 0 {
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ? AND needs_review = 1", clientID).Scan(&reviewCount)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ?", clientID).Scan(&totalLeads)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ? AND status = 'Agendou'", clientID).Scan(&totalScheduled)
|
||||
a.DB.QueryRow("SELECT COALESCE(SUM(amount),0), COUNT(*) FROM payments WHERE client_id = ? AND has_paid = 1", clientID).Scan(&totalRevenue, &totalSales)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM customers WHERE client_id IN (SELECT client_id FROM clients WHERE account_id = ?)", accountID).Scan(&totalCustomers)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM services WHERE client_id = ?", clientID).Scan(&totalServices)
|
||||
a.DB.QueryRow("SELECT COUNT(*) FROM scheduling WHERE client_id = ? AND status = 'pending'", clientID).Scan(&pendingSchedules)
|
||||
}
|
||||
|
||||
waStatus := "disconnected"
|
||||
waStyle := "color:#dc3545"
|
||||
connectLink := ""
|
||||
if a.WAConnector != nil {
|
||||
// WhatsApp status
|
||||
waConnected := false
|
||||
waPhone := ""
|
||||
if a.WAConnector != nil && clientID > 0 {
|
||||
if connected, _ := a.WAConnector.IsConnected(r.Context(), clientID); connected {
|
||||
waStatus = "connected"
|
||||
waStyle = "color:#28a745"
|
||||
} else if clientID > 0 {
|
||||
// Fallback: check DB column when in-memory state not available.
|
||||
waConnected = true
|
||||
} else {
|
||||
var dbConnected int
|
||||
a.DB.QueryRow("SELECT whatsapp_connected FROM clients WHERE client_id = ?", clientID).Scan(&dbConnected)
|
||||
if dbConnected == 1 {
|
||||
waStatus = "connected"
|
||||
waStyle = "color:#28a745"
|
||||
} else {
|
||||
connectLink = fmt.Sprintf(` <a href="/leads/connect?client_id=%d" style="color:#28a745">Connect</a>`, clientID)
|
||||
waConnected = true
|
||||
}
|
||||
} 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 := ""
|
||||
if reviewCount > 0 {
|
||||
badge = fmt.Sprintf(` <span style="background:#dc3545;color:#fff;border-radius:1rem;padding:0.15rem 0.5rem;font-size:0.8rem">%d</span>`, reviewCount)
|
||||
// Recent leads
|
||||
var recentLeads []db.Lead
|
||||
if clientID > 0 {
|
||||
leads, _ := db.ListAllLeads(a.DB, clientID, 5, 0)
|
||||
recentLeads = leads
|
||||
}
|
||||
|
||||
// Account name
|
||||
var accountName string
|
||||
a.DB.QueryRow("SELECT COALESCE(name,'User') FROM accounts WHERE account_id = ?", accountID).Scan(&accountName)
|
||||
|
||||
buf := templates.BufRender()
|
||||
|
||||
// KPI Row
|
||||
fmt.Fprintf(buf, `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">`)
|
||||
fmt.Fprint(buf, templates.KPICard("Total Leads", fmt.Sprintf("%d", totalLeads), "", "amber", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>`, 1))
|
||||
fmt.Fprint(buf, templates.KPICard("Agendamentos", fmt.Sprintf("%d", totalScheduled), "", "emerald", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>`, 2))
|
||||
fmt.Fprint(buf, templates.KPICard("Revenue", fmt.Sprintf("R$ %.2f", totalRevenue), fmt.Sprintf("%d sales", totalSales), "sky", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2-1.343-2-3-2zm0 0v1m0 0v1m0-1h1m-1 0H9m12 0a2 2 0 012 2v4.5a2.5 2.5 0 01-2.5 2.5h-15a2.5 2.5 0 01-2.5-2.5V10a2 2 0 012-2h15z"/>`, 3))
|
||||
fmt.Fprint(buf, templates.KPICard("Pending Review", fmt.Sprintf("%d", reviewCount), "", "rose", `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>`, 4))
|
||||
fmt.Fprintf(buf, `</div>`)
|
||||
|
||||
// Secondary KPIs
|
||||
fmt.Fprintf(buf, `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">`)
|
||||
secondaryKPIs := []struct{ label, value string }{
|
||||
{"Customers", fmt.Sprintf("%d", totalCustomers)},
|
||||
{"Services", fmt.Sprintf("%d", totalServices)},
|
||||
{"Pending Schedules", fmt.Sprintf("%d", pendingSchedules)},
|
||||
{"Conversion Rate", fmt.Sprintf("%.1f%%", conversionRate(totalScheduled, totalLeads))},
|
||||
}
|
||||
for _, kpi := range secondaryKPIs {
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="bg-zinc-900 border border-white/[0.06] rounded-lg p-3 animate-slide-up">
|
||||
<div class="text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1">%s</div>
|
||||
<div class="text-lg font-mono font-semibold text-zinc-200">%s</div>
|
||||
</div>`, kpi.label, kpi.value)
|
||||
}
|
||||
fmt.Fprintf(buf, `</div>`)
|
||||
|
||||
// WhatsApp Status Card
|
||||
waIcon := `<span class="w-2 h-2 rounded-full bg-rose-400 animate-pulse"></span>`
|
||||
waText := "Disconnected"
|
||||
waSub := "No active session"
|
||||
waAction := fmt.Sprintf(`<a href="/leads/connect?client_id=%d" class="btn-primary btn-sm">Connect</a>`, clientID)
|
||||
if waConnected {
|
||||
waIcon = `<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>`
|
||||
waText = "Connected"
|
||||
waSub = waPhone
|
||||
waAction = `<span class="pill pill-emerald">Online</span>`
|
||||
} else if clientID == 0 {
|
||||
waAction = `<a href="/clients" class="btn-ghost btn-sm">Create Client</a>`
|
||||
}
|
||||
if waPhone == "" && clientID > 0 {
|
||||
waSub = "Phone not configured"
|
||||
}
|
||||
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="card-industrial p-4 mb-6 border animate-slide-up stagger-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-400/5 border border-emerald-400/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-emerald-400" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.051-1.38l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.379-5.05c0-5.445 4.428-9.872 9.873-9.872 2.38 0 4.618.829 6.42 2.33a9.77 9.77 0 012.356 6.42c0 5.445-4.429 9.873-9.873 9.873m8.52-3.378a10.11 10.11 0 01-2.332 2.357 8.67 8.67 0 01-6.42 2.33c-5.28 0-9.58-4.298-9.58-9.578 0-5.28 4.3-9.58 9.58-9.58 2.38 0 4.618.83 6.42 2.33a10.11 10.11 0 012.332 2.357c1.152 1.522 1.756 3.347 1.756 5.28 0 1.934-.604 3.758-1.756 5.28"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
%s
|
||||
<span class="text-sm font-semibold text-zinc-200">%s</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 font-mono mt-0.5">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>%s</div>
|
||||
</div>
|
||||
</div>`, waIcon, waText, waSub, waAction)
|
||||
|
||||
// Recent Leads Section
|
||||
var recentHTML string
|
||||
if len(recentLeads) == 0 {
|
||||
recentHTML = string(templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>`, "No leads yet. Connect WhatsApp to start capturing."))
|
||||
} else {
|
||||
recentHTML = string(templates.TableStart([]string{"Phone", "Name", "Service", "Status", "Arrived"}))
|
||||
for _, l := range recentLeads {
|
||||
statusPill := statusPillHTML(l.Status)
|
||||
reviewBadge := ""
|
||||
if l.NeedsReview {
|
||||
reviewBadge = `<span class="ml-2 pill pill-rose">review</span>`
|
||||
}
|
||||
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
||||
recentHTML += fmt.Sprintf(`<tr>
|
||||
<td class="font-mono text-xs">%s</td>
|
||||
<td>%s%s</td>
|
||||
<td class="text-zinc-400">%s</td>
|
||||
<td>%s</td>
|
||||
<td class="text-xs text-zinc-500">%s</td>
|
||||
</tr>`, htmlEscape(l.PhoneNormalized), htmlEscape(l.Name), reviewBadge, htmlEscape(l.ServiceInterest), statusPill, arrived)
|
||||
}
|
||||
recentHTML += string(templates.TableEnd())
|
||||
}
|
||||
|
||||
fmt.Fprint(buf, templates.SectionCard("Recent Leads", "Last 5 captured entries", template.HTML(recentHTML)))
|
||||
|
||||
// Quick actions
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-4 animate-slide-up stagger-3">
|
||||
<a href="/leads/review" class="card-industrial p-4 hover:bg-white/[0.02] transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded bg-rose-400/10 flex items-center justify-center group-hover:bg-rose-400/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-200">Review Queue</div>
|
||||
<div class="text-xs text-zinc-500">%d pending</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/report" class="card-industrial p-4 hover:bg-white/[0.02] transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded bg-sky-400/10 flex items-center justify-center group-hover:bg-sky-400/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6m10 0v-6a2 2 0 012-2h2a2 2 0 012 2v6m-6 0V7a2 2 0 012-2h2a2 2 0 012 2v12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-200">Monthly Report</div>
|
||||
<div class="text-xs text-zinc-500">Performance analytics</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/clients" class="card-industrial p-4 hover:bg-white/[0.02] transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded bg-amber-400/10 flex items-center justify-center group-hover:bg-amber-400/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-200">Clients</div>
|
||||
<div class="text-xs text-zinc-500">Manage your businesses</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`, reviewCount)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<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)
|
||||
templates.WritePage(w, buf, "Dashboard", "dashboard")
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).Dashboard(w, r)
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func TestDashboardHidesConnectLinkWhenDBConnected(t *testing.T) {
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "connected") {
|
||||
t.Error("expected dashboard to show 'connected' status from DB column")
|
||||
if !strings.Contains(body, "Connected") {
|
||||
t.Error("expected dashboard to show 'Connected' status from DB column")
|
||||
}
|
||||
|
||||
if strings.Contains(body, "/leads/connect") {
|
||||
@@ -138,8 +138,8 @@ func TestDashboardShowsConnectLinkWhenDisconnected(t *testing.T) {
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "disconnected") {
|
||||
t.Error("expected dashboard to show 'disconnected' status")
|
||||
if !strings.Contains(body, "Disconnected") {
|
||||
t.Error("expected dashboard to show 'Disconnected' status")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "/clients") {
|
||||
@@ -212,8 +212,8 @@ func TestDashboardShowsConnectedStatus(t *testing.T) {
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "connected") {
|
||||
t.Error("expected dashboard to show 'connected' status")
|
||||
if !strings.Contains(body, "Connected") {
|
||||
t.Error("expected dashboard to show 'Connected' status")
|
||||
}
|
||||
|
||||
if strings.Contains(body, "/leads/connect") {
|
||||
@@ -281,8 +281,8 @@ func TestDashboardShowsConnectLinkWithClientID(t *testing.T) {
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "disconnected") {
|
||||
t.Error("expected dashboard to show 'disconnected' status")
|
||||
if !strings.Contains(body, "Disconnected") {
|
||||
t.Error("expected dashboard to show 'Disconnected' status")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "/leads/connect?client_id=") {
|
||||
|
||||
@@ -2,16 +2,16 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"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) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -35,63 +35,36 @@ func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<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),
|
||||
)
|
||||
templates.WritePage(w, buf, "Keyword Mapping", "")
|
||||
}
|
||||
|
||||
// AddServiceKeyword adds a new keyword mapping.
|
||||
// POST /leads/keywords
|
||||
func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -112,8 +85,6 @@ func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, renderKeywordTable(kws))
|
||||
}
|
||||
|
||||
// DeleteServiceKeywordHandler removes a keyword mapping.
|
||||
// DELETE /leads/keywords/{id}
|
||||
func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -128,8 +99,6 @@ func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request
|
||||
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) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -149,8 +118,6 @@ func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, renderStatusTable(statuses))
|
||||
}
|
||||
|
||||
// DeleteLeadStatusHandler removes a lead status.
|
||||
// DELETE /leads/statuses/{id}
|
||||
func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -165,13 +132,11 @@ func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, renderStatusTable(statuses))
|
||||
}
|
||||
|
||||
// --- rendering helpers -------------------------------------------------------
|
||||
|
||||
func buildServiceSelectOptions(services []string) string {
|
||||
out := ""
|
||||
for _, s := range services {
|
||||
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))
|
||||
}
|
||||
@@ -179,51 +144,47 @@ func buildServiceSelectOptions(services []string) string {
|
||||
}
|
||||
|
||||
func renderKeywordTable(kws []db.ServiceKeyword) string {
|
||||
out := `<table id="keywordTable">
|
||||
<thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead>
|
||||
<tbody>`
|
||||
out := `<div id="keywordTable"><table class="data-table"><thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead><tbody>`
|
||||
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 {
|
||||
out += fmt.Sprintf(`
|
||||
<tr>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td class="text-zinc-200">%s</td>
|
||||
<td class="font-mono text-xs text-amber-400">%s</td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>`, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID)
|
||||
}
|
||||
out += `</tbody></table>`
|
||||
out += `</tbody></table></div>`
|
||||
return out
|
||||
}
|
||||
|
||||
func renderStatusTable(statuses []db.LeadStatus) string {
|
||||
out := `<table id="statusTable">
|
||||
<thead><tr><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>`
|
||||
out := `<div id="statusTable"><table class="data-table"><thead><tr><th>Status</th><th>Actions</th></tr></thead><tbody>`
|
||||
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 {
|
||||
out += fmt.Sprintf(`
|
||||
<tr>
|
||||
<td>%s</td>
|
||||
<td><span class="pill pill-zinc">%s</span></td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>`, htmlEscape(s.StatusName), s.StatusID)
|
||||
}
|
||||
out += `</tbody></table>`
|
||||
out += `</tbody></table></div>`
|
||||
return out
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests ---
|
||||
// --- package-level shims ---
|
||||
|
||||
func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r)
|
||||
|
||||
@@ -8,12 +8,11 @@ import (
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"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) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -40,72 +39,39 @@ func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
||||
services := a.serviceNames(clientID)
|
||||
|
||||
pendingCount := len(leads)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Review Queue (%d pending)</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
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)
|
||||
buf := templates.BufRender()
|
||||
badge := ""
|
||||
if pendingCount > 0 {
|
||||
badge = fmt.Sprintf(`<span class="ml-2 pill pill-rose">%d</span>`, pendingCount)
|
||||
}
|
||||
fmt.Fprint(buf, templates.PageHeader("Review Queue", "Confirm or correct leads with unidentified service interest"))
|
||||
fmt.Fprintf(buf, `<div class="mb-4">%s</div>`, badge)
|
||||
|
||||
if len(leads) == 0 {
|
||||
fmt.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 {
|
||||
fmt.Fprintf(w, `<table>
|
||||
<thead>
|
||||
<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">`)
|
||||
|
||||
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
|
||||
fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service Interest", "Status", "Arrived", "Actions"}))
|
||||
for _, l := range leads {
|
||||
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
||||
fmt.Fprintf(w, `
|
||||
<tr id="row-%d">
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
fmt.Fprintf(buf, `<tr id="row-%d">
|
||||
<td class="font-mono text-xs text-zinc-300">%s</td>
|
||||
<td class="font-medium text-zinc-200">%s</td>
|
||||
<td>
|
||||
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML">
|
||||
<select name="service_interest">%s</select>
|
||||
<select name="status">%s</select>
|
||||
<input type="text" name="name" value="%s" placeholder="Name">
|
||||
<div class="actions">
|
||||
<button type="submit">Confirm</button>
|
||||
</div>
|
||||
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML" class="flex flex-col gap-2">
|
||||
<select name="service_interest" class="input-industrial text-xs">%s</select>
|
||||
<select name="status" class="input-industrial text-xs">%s</select>
|
||||
<input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
|
||||
<button type="submit" class="btn-primary btn-sm self-start">Confirm</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td><span class="pill pill-amber">%s</span></td>
|
||||
<td class="text-xs text-zinc-500">%s</td>
|
||||
<td>
|
||||
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML">
|
||||
<button type="submit" onclick="return confirm('Delete this lead?')">Delete</button>
|
||||
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML" style="display:inline">
|
||||
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this lead?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`,
|
||||
@@ -121,14 +87,14 @@ func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
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")
|
||||
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) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -192,7 +156,6 @@ func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, svcErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// convert domain.Leaf to db.Lead
|
||||
leads = make([]db.Lead, len(domainLeads))
|
||||
for i, dl := range domainLeads {
|
||||
leads[i] = db.Lead{
|
||||
@@ -225,98 +188,83 @@ func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) {
|
||||
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
||||
services := a.serviceNames(clientID)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<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)
|
||||
buf := templates.BufRender()
|
||||
fmt.Fprint(buf, templates.PageHeader("All Leads", "Complete lead database with filters and inline editing"))
|
||||
|
||||
for _, l := range leads {
|
||||
arrived := time.Unix(l.CreatedAt, 0).Format("02/01/2006 15:04")
|
||||
lastContact := time.Unix(l.LastContactAt, 0).Format("02/01/2006 15:04")
|
||||
rowClass := ""
|
||||
if l.NeedsReview {
|
||||
rowClass = `class="needs-review"`
|
||||
}
|
||||
fmt.Fprintf(w, `
|
||||
<tr %s id="lead-%d">
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<button onclick="document.getElementById('edit-%d').style.display='table-row'">Edit</button>
|
||||
<form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
|
||||
<button 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>
|
||||
</td>
|
||||
</tr>`,
|
||||
rowClass, l.LeadID,
|
||||
htmlEscape(l.PhoneNormalized),
|
||||
htmlEscape(l.Name),
|
||||
htmlEscape(l.ServiceInterest),
|
||||
htmlEscape(l.Status),
|
||||
htmlEscape(l.PaymentStatus),
|
||||
arrived,
|
||||
lastContact,
|
||||
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,
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, `</tbody></table></body></html>`)
|
||||
if len(leads) == 0 {
|
||||
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>`, "No leads captured yet. Connect WhatsApp to start."))
|
||||
} else {
|
||||
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up"><div class="overflow-x-auto">`)
|
||||
fmt.Fprint(buf, templates.TableStart([]string{"Phone", "Name", "Service", "Status", "Payment", "Arrived", "Last Contact", "Actions"}))
|
||||
for _, l := range leads {
|
||||
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
||||
lastContact := time.Unix(l.LastContactAt, 0).Format("02/01 15:04")
|
||||
rowClass := ""
|
||||
if l.NeedsReview {
|
||||
rowClass = `class="bg-amber-400/[0.03]"`
|
||||
}
|
||||
statusPill := statusPillHTML(l.Status)
|
||||
fmt.Fprintf(buf, `<tr %s id="lead-%d">
|
||||
<td class="font-mono text-xs text-zinc-300">%s</td>
|
||||
<td class="font-medium text-zinc-200">%s %s</td>
|
||||
<td class="text-zinc-400 text-xs">%s</td>
|
||||
<td>%s</td>
|
||||
<td class="text-xs text-zinc-400">%s</td>
|
||||
<td class="text-xs text-zinc-500">%s</td>
|
||||
<td class="text-xs text-zinc-500">%s</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="document.getElementById('edit-%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
|
||||
<form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
|
||||
<button class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
rowClass, l.LeadID,
|
||||
htmlEscape(l.PhoneNormalized),
|
||||
htmlEscape(l.Name),
|
||||
map[bool]string{true: `<span class="ml-1.5 pill pill-rose text-[9px]">review</span>`, false: ""}[l.NeedsReview],
|
||||
htmlEscape(l.ServiceInterest),
|
||||
statusPill,
|
||||
htmlEscape(l.PaymentStatus),
|
||||
arrived,
|
||||
lastContact,
|
||||
l.LeadID,
|
||||
l.LeadID, l.LeadID,
|
||||
)
|
||||
}
|
||||
fmt.Fprint(buf, templates.TableEnd())
|
||||
fmt.Fprintf(buf, `</div></div>`)
|
||||
|
||||
// Edit rows
|
||||
for _, l := range leads {
|
||||
fmt.Fprintf(buf, `
|
||||
<div id="edit-%d" class="hidden mt-4 animate-slide-up">
|
||||
<div class="card-industrial p-5 border-amber-400/10">
|
||||
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Edit Lead #%d</h3>
|
||||
<form hx-put="/leads/%d/review" hx-target="#lead-%d" hx-swap="outerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<input type="text" name="name" value="%s" class="input-industrial text-xs" placeholder="Name">
|
||||
<select name="service_interest" class="input-industrial text-xs">%s</select>
|
||||
<select name="status" class="input-industrial text-xs">%s</select>
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="btn-primary btn-sm">Save</button>
|
||||
<button type="button" onclick="document.getElementById('edit-%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`, l.LeadID, l.LeadID, l.LeadID, l.LeadID, htmlEscape(l.Name), buildServiceOptions(services, l.ServiceInterest), buildStatusOptions(statuses, l.Status), l.LeadID)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
templates.WritePage(w, buf, "All Leads", "leads")
|
||||
}
|
||||
|
||||
// DeleteLeadNew handles DELETE /leads/{id}
|
||||
func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
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.
|
||||
// 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 {
|
||||
rows, err := a.DB.Query(
|
||||
"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 {
|
||||
// Ensure "Não especificou" is always first.
|
||||
hasDefault := false
|
||||
for _, n := range names {
|
||||
if n == "Não especificou" {
|
||||
@@ -361,7 +306,6 @@ func (a *App) serviceNames(clientID int64) []string {
|
||||
return names
|
||||
}
|
||||
}
|
||||
// Fallback: hardcoded defaults for a fresh install with no services yet.
|
||||
return []string{
|
||||
"Não especificou",
|
||||
"Head Spa",
|
||||
@@ -373,8 +317,6 @@ func (a *App) serviceNames(clientID int64) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// --- rendering helpers -------------------------------------------------------
|
||||
|
||||
func buildServiceOptions(services []string, selected string) string {
|
||||
out := ""
|
||||
for _, s := range services {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
"go-crm/internal/whatsapp"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -32,8 +34,8 @@ func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
args := []interface{}{accountID}
|
||||
|
||||
if search != "" {
|
||||
query += " AND (name LIKE ? OR phone LIKE ?)"
|
||||
searchPat := "%" + search + "%"
|
||||
query += " AND (cu.name LIKE ? OR cu.phone LIKE ?)"
|
||||
searchPat := "%%" + search + "%%"
|
||||
args = append(args, searchPat, searchPat)
|
||||
}
|
||||
|
||||
@@ -56,72 +58,50 @@ func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
customers = append(customers, c)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>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; }
|
||||
.search-box { margin-bottom: 1rem; }
|
||||
.edit-row { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Leads</h1>
|
||||
<div class="search-box">
|
||||
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML">
|
||||
<input type="text" name="search" placeholder="Search by name or phone" value="` + search + `">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<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">
|
||||
`))
|
||||
buf := templates.BufRender()
|
||||
fmt.Fprint(buf, templates.PageHeader("Leads", "Search and manage customer leads"))
|
||||
|
||||
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;"
|
||||
// Search
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="card-industrial p-4 mb-6 animate-slide-up">
|
||||
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML" class="flex gap-3">
|
||||
<input type="text" name="search" placeholder="Search by name or phone..." value="%s" class="input-industrial max-w-md">
|
||||
<button type="submit" class="btn-primary">Search</button>
|
||||
</form>
|
||||
</div>`, htmlEscape(search))
|
||||
|
||||
if len(customers) == 0 {
|
||||
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>`, "No leads found. Try a different search or connect WhatsApp to capture leads."))
|
||||
} else {
|
||||
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up stagger-1"><div class="overflow-x-auto">`)
|
||||
fmt.Fprint(buf, templates.TableStart([]string{"Name", "Phone", "Birth Date", "Instagram", "WhatsApp", "Actions"}))
|
||||
for _, c := range customers {
|
||||
waStatus := `<span class="text-zinc-600 text-xs">—</span>`
|
||||
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
|
||||
waStatus = fmt.Sprintf(`<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span><span class="font-mono text-xs">%s</span></span>`, htmlEscape(c.WhatsAppNumber))
|
||||
}
|
||||
fmt.Fprintf(buf, `<tr>
|
||||
<td class="font-medium text-zinc-200">%s</td>
|
||||
<td class="font-mono text-xs">%s</td>
|
||||
<td class="text-xs text-zinc-400">%s</td>
|
||||
<td class="text-xs text-zinc-400">%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="document.getElementById('editLead%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
|
||||
<form hx-delete="/leads/%d" hx-target="closest tr" hx-swap="outerHTML" style="display:inline">
|
||||
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), waStatus, c.CustomerID, c.CustomerID)
|
||||
}
|
||||
w.Write([]byte(`<tr>
|
||||
<td>` + c.Name + `</td>
|
||||
<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>`))
|
||||
fmt.Fprint(buf, templates.TableEnd())
|
||||
fmt.Fprintf(buf, `</div></div>`)
|
||||
}
|
||||
|
||||
w.Write([]byte(`</tbody></table>
|
||||
<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, "Leads", "leads")
|
||||
}
|
||||
|
||||
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.Write([]byte("OK"))
|
||||
w.Write([]byte(""))
|
||||
}
|
||||
|
||||
func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -179,81 +159,71 @@ func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Connect WhatsApp</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 2rem; text-align: center; }
|
||||
#qrcode { margin: 2rem auto; display: flex; justify-content: center; }
|
||||
#status { padding: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Connect WhatsApp</h1>
|
||||
<p>Scan the QR code below with your WhatsApp app to connect</p>
|
||||
<div id="qrcode"></div>
|
||||
<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(data => {
|
||||
if (data.qr) {
|
||||
if (data.qr !== lastQR) {
|
||||
lastQR = data.qr;
|
||||
document.getElementById('qrcode').innerHTML = '';
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
text: data.qr,
|
||||
width: 256,
|
||||
height: 256
|
||||
});
|
||||
buf := templates.BufRender()
|
||||
fmt.Fprint(buf, templates.PageHeader("Connect WhatsApp", fmt.Sprintf("Scan QR code to link %s", htmlEscape(client.Name))))
|
||||
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="max-w-lg mx-auto animate-slide-up">
|
||||
<div class="card-industrial p-8 text-center">
|
||||
<div id="qrcode" class="flex justify-center mb-6 min-h-[256px] items-center">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<p id="status" class="text-sm text-zinc-400 font-mono">Loading QR code...</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
var lastQR = '';
|
||||
function pollQR() {
|
||||
fetch('/leads/qr?client_id=%d')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.qr) {
|
||||
if (data.qr !== lastQR) {
|
||||
lastQR = data.qr;
|
||||
document.getElementById('qrcode').innerHTML = '';
|
||||
new QRCode(document.getElementById('qrcode'), { text: data.qr, width: 256, height: 256 });
|
||||
}
|
||||
document.getElementById('status').textContent = 'Scan with WhatsApp';
|
||||
setTimeout(pollQR, 5000);
|
||||
} else if (data.status === 'ready') {
|
||||
document.getElementById('status').textContent = 'Connected! Verifying phone...';
|
||||
document.getElementById('qrcode').innerHTML = '<div class="w-16 h-16 rounded-full bg-emerald-400/10 flex items-center justify-center mx-auto"><svg class="w-8 h-8 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>';
|
||||
setTimeout(() => {
|
||||
fetch('/leads/verify/%d')
|
||||
.then(r => r.json())
|
||||
.then(v => {
|
||||
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
|
||||
if (v.match === 'yes') {
|
||||
document.getElementById('status').innerHTML = msg + ' matches client phone — <span class="text-emerald-400">verified</span>';
|
||||
} else if (v.match === 'no') {
|
||||
document.getElementById('status').innerHTML = msg + ' does NOT match client phone — <span class="text-rose-400">mismatch</span>';
|
||||
} else {
|
||||
document.getElementById('status').textContent = msg + ' (client phone unknown)';
|
||||
}
|
||||
document.getElementById('status').textContent = 'Scan with WhatsApp';
|
||||
setTimeout(pollQR, 5000);
|
||||
} else if (data.status === 'ready') {
|
||||
document.getElementById('status').textContent = 'Connected! Verifying phone...';
|
||||
document.getElementById('qrcode').innerHTML = '✓';
|
||||
setTimeout(() => {
|
||||
fetch('/leads/verify/` + strconv.FormatInt(client.ClientID, 10) + `')
|
||||
.then(r => r.json())
|
||||
.then(v => {
|
||||
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
|
||||
if (v.match === 'yes') {
|
||||
document.getElementById('status').textContent = msg + ' — matches ' + (v.client_phone || '') + ' ✓';
|
||||
document.getElementById('status').style.color = '#28a745';
|
||||
} else if (v.match === 'no') {
|
||||
document.getElementById('status').textContent = msg + ' — does NOT match client phone ' + (v.client_phone || '') + ' ⚠';
|
||||
document.getElementById('status').style.color = '#dc3545';
|
||||
} else {
|
||||
document.getElementById('status').textContent = msg + ' (client phone unknown — verify manually)';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('status').textContent = 'Connected! (could not verify phone)';
|
||||
});
|
||||
}, 1500);
|
||||
} else if (data.status === 'error') {
|
||||
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...';
|
||||
setTimeout(pollQR, 8000);
|
||||
} else {
|
||||
document.getElementById('status').textContent = 'Status: ' + data.status;
|
||||
setTimeout(pollQR, 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('status').textContent = 'Connection error — retrying...';
|
||||
setTimeout(pollQR, 5000);
|
||||
});
|
||||
}
|
||||
pollQR();
|
||||
</script>
|
||||
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('status').textContent = 'Connected! (could not verify phone)';
|
||||
});
|
||||
}, 1500);
|
||||
} else if (data.status === 'error') {
|
||||
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' retrying...';
|
||||
setTimeout(pollQR, 8000);
|
||||
} else {
|
||||
document.getElementById('status').textContent = 'Status: ' + data.status;
|
||||
setTimeout(pollQR, 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('status').textContent = 'Connection error retrying...';
|
||||
setTimeout(pollQR, 5000);
|
||||
});
|
||||
}
|
||||
pollQR();
|
||||
</script>`, client.ClientID, client.ClientID)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
templates.WritePage(w, buf, "Connect WhatsApp", "leads")
|
||||
}
|
||||
|
||||
func jsonEscape(s string) string {
|
||||
@@ -349,8 +319,8 @@ func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
match := "unknown"
|
||||
if client.WhatsAppNumber != "" && client.Phone != "" {
|
||||
cleanWA := strings.TrimPrefix(client.WhatsAppNumber, "+")
|
||||
cleanClient := strings.TrimPrefix(client.Phone, "+")
|
||||
cleanWA := cleanPhone(client.WhatsAppNumber)
|
||||
cleanClient := cleanPhone(client.Phone)
|
||||
if cleanWA == cleanClient {
|
||||
match = "yes"
|
||||
} 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) + `"}`))
|
||||
}
|
||||
|
||||
func cleanPhone(s string) string {
|
||||
s = strings.TrimPrefix(s, "+")
|
||||
s = strings.TrimPrefix(s, "55")
|
||||
return s
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests ---
|
||||
|
||||
func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -45,32 +47,105 @@ func (a *App) ListPayments(w http.ResponseWriter, r *http.Request) {
|
||||
clientNames := make(map[int64]string)
|
||||
customerNames := make(map[int64]string)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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-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">`))
|
||||
for _, p := range payments {
|
||||
paid := "No"
|
||||
if p.HasPaid {
|
||||
paid = "Yes"
|
||||
buf := templates.BufRender()
|
||||
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 {
|
||||
paidPill := `<span class="pill pill-rose">unpaid</span>`
|
||||
if p.HasPaid {
|
||||
paidPill = `<span class="pill pill-emerald">paid</span>`
|
||||
}
|
||||
clientName := clientNames[p.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(p.ClientID, 10)
|
||||
}
|
||||
customerName := customerNames[p.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(p.CustomerID, 10)
|
||||
}
|
||||
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)
|
||||
}
|
||||
clientName := clientNames[p.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(p.ClientID, 10)
|
||||
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)
|
||||
}
|
||||
customerName := customerNames[p.CustomerID]
|
||||
if customerName == "" {
|
||||
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>`))
|
||||
}
|
||||
w.Write([]byte(`</tbody></table></body></html>`))
|
||||
|
||||
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) {
|
||||
@@ -111,7 +186,7 @@ func (a *App) ViewPayment(w http.ResponseWriter, r *http.Request) {
|
||||
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 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)
|
||||
@@ -121,8 +196,38 @@ func (a *App) ViewPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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.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) {
|
||||
@@ -151,11 +256,11 @@ func (a *App) DeletePayment(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests ---
|
||||
// --- package-level shims ---
|
||||
|
||||
func ListPayments(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).ListPayments(w, r)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -45,28 +47,93 @@ func (a *App) ListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||
clientNames := make(map[int64]string)
|
||||
customerNames := make(map[int64]string)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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-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">`))
|
||||
for _, q := range questions {
|
||||
clientName := clientNames[q.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(q.ClientID, 10)
|
||||
buf := templates.BufRender()
|
||||
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 {
|
||||
clientName := clientNames[q.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(q.ClientID, 10)
|
||||
}
|
||||
customerName := customerNames[q.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(q.CustomerID, 10)
|
||||
}
|
||||
statusPill := `<span class="pill pill-amber">pending</span>`
|
||||
if q.Status == "answered" {
|
||||
statusPill = `<span class="pill pill-emerald">answered</span>`
|
||||
}
|
||||
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)
|
||||
}
|
||||
customerName := customerNames[q.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(q.CustomerID, 10)
|
||||
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.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>`))
|
||||
}
|
||||
w.Write([]byte(`</tbody></table></body></html>`))
|
||||
|
||||
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) {
|
||||
@@ -109,8 +176,29 @@ func (a *App) ViewQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
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.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) {
|
||||
@@ -141,6 +229,8 @@ func (a *App) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -169,88 +259,93 @@ func (a *App) ListAnswers(w http.ResponseWriter, r *http.Request) {
|
||||
var questionOptions string
|
||||
questionText := make(map[int64]string)
|
||||
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
|
||||
}
|
||||
|
||||
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; }
|
||||
.edit-row { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Answers</h1>
|
||||
<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>
|
||||
buf := templates.BufRender()
|
||||
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>`
|
||||
fmt.Fprint(buf, templates.PageHeader("Answers", "Responses to customer questions", actions))
|
||||
|
||||
fmt.Fprintf(buf, `
|
||||
<div id="answerForm" 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 Answer</h3>
|
||||
<form hx-post="/answers" hx-target="#answerList" hx-swap="innerHTML" class="grid grid-cols-1 gap-4">
|
||||
<select name="question_id" required class="input-industrial max-w-md"><option value="">Select Question</option>%s</select>
|
||||
<textarea name="answer" placeholder="Answer text" required class="input-industrial" rows="3"></textarea>
|
||||
<div><button type="submit" class="btn-primary">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Client</th><th>Customer</th><th>Question</th><th>Answer</th><th>Status</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="answerList">
|
||||
`))
|
||||
for _, a2 := range answers {
|
||||
qText := a2.QuestionText
|
||||
if qText == "" {
|
||||
qText = questionText[a2.QuestionID]
|
||||
</div>`, questionOptions)
|
||||
|
||||
if len(answers) == 0 {
|
||||
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."))
|
||||
} 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 {
|
||||
qText := a2.QuestionText
|
||||
if qText == "" {
|
||||
qText = strconv.FormatInt(a2.QuestionID, 10)
|
||||
qText = questionText[a2.QuestionID]
|
||||
if qText == "" {
|
||||
qText = strconv.FormatInt(a2.QuestionID, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
clientName := a2.ClientName
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(a2.ClientID, 10)
|
||||
}
|
||||
customerName := a2.CustomerName
|
||||
if customerName == "" {
|
||||
customerName = "Unknown"
|
||||
}
|
||||
w.Write([]byte(`
|
||||
<tr>
|
||||
<td>` + clientName + `</td>
|
||||
<td>` + customerName + `</td>
|
||||
<td>` + qText + `</td>
|
||||
<td>` + a2.Answer + `</td>
|
||||
<td>` + a2.Status + `</td>
|
||||
clientName := a2.ClientName
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(a2.ClientID, 10)
|
||||
}
|
||||
customerName := a2.CustomerName
|
||||
if customerName == "" {
|
||||
customerName = "Unknown"
|
||||
}
|
||||
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-[200px] truncate">%s</td>
|
||||
<td class="text-zinc-400 text-xs max-w-[300px] truncate">%s</td>
|
||||
<td>
|
||||
<a href="/answers/` + strconv.FormatInt(a2.AnswerID, 10) + `">View</a>
|
||||
<button type="button" onclick="document.getElementById('editA` + strconv.FormatInt(a2.AnswerID, 10) + `').style.display='table-row'">Edit</button>
|
||||
<form method="DELETE" style="display:inline" hx-delete="/answers/` + strconv.FormatInt(a2.AnswerID, 10) + `" hx-target="closest tr">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/answers/%d" class="btn-ghost btn-sm">View</a>
|
||||
<button type="button" onclick="document.getElementById('editA%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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>`))
|
||||
</tr>`, htmlEscape(clientName), htmlEscape(customerName), htmlEscape(qText), htmlEscape(a2.Answer), a2.AnswerID, a2.AnswerID, a2.AnswerID)
|
||||
}
|
||||
fmt.Fprint(buf, templates.TableEnd())
|
||||
fmt.Fprintf(buf, `</div></div>`)
|
||||
|
||||
for _, a2 := range answers {
|
||||
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.Write([]byte(`
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
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) {
|
||||
@@ -307,8 +402,24 @@ func (a *App) ViewAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
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.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) {
|
||||
@@ -338,7 +449,7 @@ func (a *App) DeleteAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).ListQuestions(w, r)
|
||||
|
||||
@@ -2,9 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/templates"
|
||||
)
|
||||
|
||||
type serviceCount struct {
|
||||
@@ -25,8 +28,6 @@ type reportData struct {
|
||||
MonthLabel string
|
||||
}
|
||||
|
||||
// MonthlyReport renders the monthly performance report for the current month.
|
||||
// GET /report
|
||||
func (a *App) MonthlyReport(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
@@ -120,100 +121,99 @@ func (a *App) MonthlyReport(w http.ResponseWriter, r *http.Request) {
|
||||
var reviewCount int
|
||||
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")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<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),
|
||||
)
|
||||
templates.WritePage(w, buf, "Monthly Report", "report")
|
||||
}
|
||||
|
||||
func renderServiceTable(counts []serviceCount, total int) string {
|
||||
func renderServiceTable(counts []serviceCount, total int) template.HTML {
|
||||
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 {
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
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>`,
|
||||
htmlEscape(sc.Name), sc.Count, pct)
|
||||
barWidth := int(pct)
|
||||
if barWidth > 100 {
|
||||
barWidth = 100
|
||||
}
|
||||
out += fmt.Sprintf(`<tr>
|
||||
<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 += `</tbody></table>`
|
||||
return out
|
||||
out += string(templates.TableEnd())
|
||||
return template.HTML(out)
|
||||
}
|
||||
|
||||
func renderTopServicesTable(counts []serviceCount) string {
|
||||
func renderTopServicesTable(counts []serviceCount) template.HTML {
|
||||
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 {
|
||||
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>`
|
||||
return out
|
||||
out += string(templates.TableEnd())
|
||||
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) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).MonthlyReport(w, r)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -53,36 +55,116 @@ func (a *App) ListSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
customerNames := make(map[int64]string)
|
||||
serviceNames := make(map[int64]string)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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-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">`))
|
||||
for _, s := range schedules {
|
||||
clientName := clientNames[s.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(s.ClientID, 10)
|
||||
buf := templates.BufRender()
|
||||
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 {
|
||||
clientName := clientNames[s.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(s.ClientID, 10)
|
||||
}
|
||||
customerName := customerNames[s.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(s.CustomerID, 10)
|
||||
}
|
||||
serviceName := serviceNames[s.ServiceID]
|
||||
if serviceName == "" {
|
||||
serviceName = strconv.FormatInt(s.ServiceID, 10)
|
||||
}
|
||||
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)
|
||||
}
|
||||
customerName := customerNames[s.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(s.CustomerID, 10)
|
||||
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)
|
||||
}
|
||||
serviceName := serviceNames[s.ServiceID]
|
||||
if serviceName == "" {
|
||||
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>`))
|
||||
}
|
||||
w.Write([]byte(`</tbody></table></body></html>`))
|
||||
|
||||
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>`
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.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) {
|
||||
@@ -160,7 +269,7 @@ func (a *App) DeleteSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).ListSchedules(w, r)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -37,15 +39,75 @@ func (a *App) ListServices(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var clientOptions string
|
||||
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.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">`))
|
||||
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>`))
|
||||
templates.WritePage(w, buf, "Services", "services")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.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) {
|
||||
@@ -128,7 +215,7 @@ func (a *App) DeleteService(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).ListServices(w, r)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestListClientsWhatsAppStatusIndicators(t *testing.T) {
|
||||
t.Error("expected Connect link for clients without active connection")
|
||||
}
|
||||
|
||||
greenDot := `color:#28a745`
|
||||
greenDot := `bg-emerald-400`
|
||||
if !strings.Contains(body, greenDot) {
|
||||
t.Error("expected green dot for connected client")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user